<?php

/*
 * PHP-Cookie (https://github.com/delight-im/PHP-Cookie)
 * Copyright (c) delight.im (https://www.delight.im/)
 * Licensed under the MIT License (https://opensource.org/licenses/MIT)
 */

namespace Hura8\System\Security;

/**
 * Modern cookie management for PHP
 *
 * Cookies are a mechanism for storing data in the client's web browser and identifying returning clients on subsequent visits
 *
 * All cookies that have successfully been set will automatically be included in the global `$_COOKIE` array with future requests
 *
 * You can set a new cookie using the static method `Cookie::setcookie(...)` which is compatible to PHP's built-in `setcookie(...)` function
 *
 * Alternatively, you can construct an instance of this class, set properties individually, and finally call `save()`
 *
 * Note that cookies must always be set before the HTTP headers are sent to the client, i.e. before the actual output starts
 */
final class Cookie
{

    /** @var string name prefix indicating that the cookie must be from a secure origin (i.e. HTTPS) and the 'secure' attribute must be set */
    const PREFIX_SECURE = '__Secure-';
    /** @var string name prefix indicating that the 'domain' attribute must *not* be set, the 'path' attribute must be '/' and the effects of {@see PREFIX_SECURE} apply as well */
    const PREFIX_HOST = '__Host-';
    const HEADER_PREFIX = 'Set-Cookie: ';
    const SAME_SITE_RESTRICTION_NONE = 'None';
    const SAME_SITE_RESTRICTION_LAX = 'Lax';
    const SAME_SITE_RESTRICTION_STRICT = 'Strict';

    /** @var string the name of the cookie which is also the key for future accesses via `$_COOKIE[...]` */
    private $name;
    /** @var mixed|null the value of the cookie that will be stored on the client's machine */
    private $value;
    /** @var int the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds` */
    private $expiryTime;
    /** @var string the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory */
    private $path;
    /** @var string|null the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains) */
    private $domain;
    /** @var bool indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages */
    private $httpOnly;
    /** @var bool indicates that the cookie should be sent back by the client over secure HTTPS connections only */
    private $secureOnly;
    /** @var string|null indicates that the cookie should not be sent along with cross-site requests (either `null`, `None`, `Lax` or `Strict`) */
    private $sameSiteRestriction;

    /**
     * Prepares a new cookie
     *
     * @param string $name the name of the cookie which is also the key for future accesses via `$_COOKIE[...]`
     */
    public function __construct($name)
    {
        $this->name = $name;
        $this->value = null;
        $this->expiryTime = 0;
        $this->path = '/';
        $this->domain = null;
        $this->httpOnly = true;
        $this->secureOnly = false;
        $this->sameSiteRestriction = self::SAME_SITE_RESTRICTION_LAX;
    }

    /**
     * Returns the name of the cookie
     *
     * @return string the name of the cookie which is also the key for future accesses via `$_COOKIE[...]`
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * Returns the value of the cookie
     *
     * @return mixed|null the value of the cookie that will be stored on the client's machine
     */
    public function getValue()
    {
        return $this->value;
    }

    /**
     * Sets the value for the cookie
     *
     * @param mixed|null $value the value of the cookie that will be stored on the client's machine
     * @return static this instance for chaining
     */
    public function setValue($value)
    {
        $this->value = $value;

        return $this;
    }

    /**
     * Returns the expiry time of the cookie
     *
     * @return int the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds`
     */
    public function getExpiryTime()
    {
        return $this->expiryTime;
    }

    /**
     * Sets the expiry time for the cookie
     *
     * @param int $expiryTime the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds`
     * @return static this instance for chaining
     */
    public function setExpiryTime($expiryTime)
    {
        $this->expiryTime = $expiryTime;

        return $this;
    }

    /**
     * Returns the maximum age of the cookie (i.e. the remaining lifetime)
     *
     * @return int the maximum age of the cookie in seconds
     */
    public function getMaxAge()
    {
        return $this->expiryTime - \time();
    }

    /**
     * Sets the expiry time for the cookie based on the specified maximum age (i.e. the remaining lifetime)
     *
     * @param int $maxAge the maximum age for the cookie in seconds
     * @return static this instance for chaining
     */
    public function setMaxAge($maxAge)
    {
        $this->expiryTime = \time() + $maxAge;

        return $this;
    }

    /**
     * Returns the path of the cookie
     *
     * @return string the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory
     */
    public function getPath()
    {
        return $this->path;
    }

    /**
     * Sets the path for the cookie
     *
     * @param string $path the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory
     * @return static this instance for chaining
     */
    public function setPath($path)
    {
        $this->path = $path;

        return $this;
    }

    /**
     * Returns the domain of the cookie
     *
     * @return string|null the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains)
     */
    public function getDomain()
    {
        return $this->domain;
    }

    /**
     * Sets the domain for the cookie
     *
     * @param string|null $domain the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains)
     * @return static this instance for chaining
     */
    public function setDomain($domain = null)
    {
        $this->domain = self::normalizeDomain($domain);

        return $this;
    }

    /**
     * Returns whether the cookie should be accessible through HTTP only
     *
     * @return bool whether the cookie should be accessible through the HTTP protocol only and not through scripting languages
     */
    public function isHttpOnly()
    {
        return $this->httpOnly;
    }

    /**
     * Sets whether the cookie should be accessible through HTTP only
     *
     * @param bool $httpOnly indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages
     * @return static this instance for chaining
     */
    public function setHttpOnly($httpOnly)
    {
        $this->httpOnly = $httpOnly;

        return $this;
    }

    /**
     * Returns whether the cookie should be sent over HTTPS only
     *
     * @return bool whether the cookie should be sent back by the client over secure HTTPS connections only
     */
    public function isSecureOnly()
    {
        return $this->secureOnly;
    }

    /**
     * Sets whether the cookie should be sent over HTTPS only
     *
     * @param bool $secureOnly indicates that the cookie should be sent back by the client over secure HTTPS connections only
     * @return static this instance for chaining
     */
    public function setSecureOnly($secureOnly)
    {
        $this->secureOnly = $secureOnly;

        return $this;
    }

    /**
     * Returns the same-site restriction of the cookie
     *
     * @return string|null whether the cookie should not be sent along with cross-site requests (either `null`, `None`, `Lax` or `Strict`)
     */
    public function getSameSiteRestriction()
    {
        return $this->sameSiteRestriction;
    }

    /**
     * Sets the same-site restriction for the cookie
     *
     * @param string|null $sameSiteRestriction indicates that the cookie should not be sent along with cross-site requests (either `null`, `None`, `Lax` or `Strict`)
     * @return static this instance for chaining
     */
    public function setSameSiteRestriction($sameSiteRestriction)
    {
        $this->sameSiteRestriction = $sameSiteRestriction;

        return $this;
    }

    /**
     * Saves the cookie
     *
     * @return bool whether the cookie header has successfully been sent (and will *probably* cause the client to set the cookie)
     */
    public function save()
    {
        return self::addHttpHeader((string)$this);
    }

    /**
     * Saves the cookie and immediately creates the corresponding variable in the superglobal `$_COOKIE` array
     *
     * The variable would otherwise only be available starting from the next HTTP request
     *
     * @return bool whether the cookie header has successfully been sent (and will *probably* cause the client to set the cookie)
     */
    public function saveAndSet()
    {
        $_COOKIE[$this->name] = $this->value;

        return $this->save();
    }

    /**
     * Deletes the cookie
     *
     * @return bool whether the cookie header has successfully been sent (and will *probably* cause the client to delete the cookie)
     */
    public function delete()
    {
        // create a temporary copy of this cookie so that it isn't corrupted
        $copiedCookie = clone $this;
        // set the copied cookie's value to an empty string which internally sets the required options for a deletion
        $copiedCookie->setValue('');

        // save the copied "deletion" cookie
        return $copiedCookie->save();
    }

    /**
     * Deletes the cookie and immediately removes the corresponding variable from the superglobal `$_COOKIE` array
     *
     * The variable would otherwise only be deleted at the start of the next HTTP request
     *
     * @return bool whether the cookie header has successfully been sent (and will *probably* cause the client to delete the cookie)
     */
    public function deleteAndUnset()
    {
        unset($_COOKIE[$this->name]);

        return $this->delete();
    }

    public function __toString()
    {
        return self::buildCookieHeader($this->name, $this->value, $this->expiryTime, $this->path, $this->domain, $this->secureOnly, $this->httpOnly, $this->sameSiteRestriction);
    }

    /**
     * Sets a new cookie in a way compatible to PHP's `setcookie(...)` function
     *
     * @param string $name the name of the cookie which is also the key for future accesses via `$_COOKIE[...]`
     * @param mixed|null $value the value of the cookie that will be stored on the client's machine
     * @param int $expiryTime the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds`
     * @param string|null $path the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory
     * @param string|null $domain the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains)
     * @param bool $secureOnly indicates that the cookie should be sent back by the client over secure HTTPS connections only
     * @param bool $httpOnly indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages
     * @param string|null $sameSiteRestriction indicates that the cookie should not be sent along with cross-site requests (either `null`, `None`, `Lax` or `Strict`)
     * @return bool whether the cookie header has successfully been sent (and will *probably* cause the client to set the cookie)
     */
    public static function setcookie($name, $value = null, $expiryTime = 0, $path = null, $domain = null, $secureOnly = false, $httpOnly = false, $sameSiteRestriction = null)
    {
        return self::addHttpHeader(
            self::buildCookieHeader($name, $value, $expiryTime, $path, $domain, $secureOnly, $httpOnly, $sameSiteRestriction)
        );
    }

    /**
     * Builds the HTTP header that can be used to set a cookie with the specified options
     *
     * @param string $name the name of the cookie which is also the key for future accesses via `$_COOKIE[...]`
     * @param mixed|null $value the value of the cookie that will be stored on the client's machine
     * @param int $expiryTime the Unix timestamp indicating the time that the cookie will expire at, i.e. usually `time() + $seconds`
     * @param string|null $path the path on the server that the cookie will be valid for (including all sub-directories), e.g. an empty string for the current directory or `/` for the root directory
     * @param string|null $domain the domain that the cookie will be valid for (including subdomains) or `null` for the current host (excluding subdomains)
     * @param bool $secureOnly indicates that the cookie should be sent back by the client over secure HTTPS connections only
     * @param bool $httpOnly indicates that the cookie should be accessible through the HTTP protocol only and not through scripting languages
     * @param string|null $sameSiteRestriction indicates that the cookie should not be sent along with cross-site requests (either `null`, `None`, `Lax` or `Strict`)
     * @return string the HTTP header
     */
    public static function buildCookieHeader($name, $value = null, $expiryTime = 0, $path = null, $domain = null, $secureOnly = false, $httpOnly = false, $sameSiteRestriction = null)
    {
        if (self::isNameValid($name)) {
            $name = (string)$name;
        } else {
            return null;
        }

        if (self::isExpiryTimeValid($expiryTime)) {
            $expiryTime = (int)$expiryTime;
        } else {
            return null;
        }

        $forceShowExpiry = false;

        if (\is_null($value) || $value === false || $value === '') {
            $value = 'deleted';
            $expiryTime = 0;
            $forceShowExpiry = true;
        }

        $maxAgeStr = self::formatMaxAge($expiryTime, $forceShowExpiry);
        $expiryTimeStr = self::formatExpiryTime($expiryTime, $forceShowExpiry);

        $headerStr = self::HEADER_PREFIX . $name . '=' . \urlencode($value);

        if (!\is_null($expiryTimeStr)) {
            $headerStr .= '; expires=' . $expiryTimeStr;
        }

        // The `Max-Age` property is supported on PHP 5.5+ only (https://bugs.php.net/bug.php?id=23955).
        if (\PHP_VERSION_ID >= 50500) {
            if (!\is_null($maxAgeStr)) {
                $headerStr .= '; Max-Age=' . $maxAgeStr;
            }
        }

        if (!empty($path) || $path === 0) {
            $headerStr .= '; path=' . $path;
        }

        if (!empty($domain) || $domain === 0) {
            $headerStr .= '; domain=' . $domain;
        }

        if ($secureOnly) {
            $headerStr .= '; secure';
        }

        if ($httpOnly) {
            $headerStr .= '; httponly';
        }

        if ($sameSiteRestriction === self::SAME_SITE_RESTRICTION_NONE) {
            // if the 'secure' attribute is missing
            if (!$secureOnly) {
                \trigger_error('When the \'SameSite\' attribute is set to \'None\', the \'secure\' attribute should be set as well', \E_USER_WARNING);
            }

            $headerStr .= '; SameSite=None';
        } elseif ($sameSiteRestriction === self::SAME_SITE_RESTRICTION_LAX) {
            $headerStr .= '; SameSite=Lax';
        } elseif ($sameSiteRestriction === self::SAME_SITE_RESTRICTION_STRICT) {
            $headerStr .= '; SameSite=Strict';
        }

        return $headerStr;
    }

    /**
     * Parses the given cookie header and returns an equivalent cookie instance
     *
     * @param string $cookieHeader the cookie header to parse
     * @return Cookie|null the cookie instance or `null`
     */
    public static function parse($cookieHeader)
    {
        if (empty($cookieHeader)) {
            return null;
        }

        if (\preg_match('/^' . self::HEADER_PREFIX . '(.*?)=(.*?)(?:; (.*?))?$/i', $cookieHeader, $matches)) {
            $cookie = new self($matches[1]);
            $cookie->setPath(null);
            $cookie->setHttpOnly(false);
            $cookie->setValue(
                \urldecode($matches[2])
            );
            $cookie->setSameSiteRestriction(null);

            if (\count($matches) >= 4) {
                $attributes = \explode('; ', $matches[3]);

                foreach ($attributes as $attribute) {
                    if (\strcasecmp($attribute, 'HttpOnly') === 0) {
                        $cookie->setHttpOnly(true);
                    } elseif (\strcasecmp($attribute, 'Secure') === 0) {
                        $cookie->setSecureOnly(true);
                    } elseif (\stripos($attribute, 'Expires=') === 0) {
                        $cookie->setExpiryTime((int)\strtotime(\substr($attribute, 8)));
                    } elseif (\stripos($attribute, 'Domain=') === 0) {
                        $cookie->setDomain(\substr($attribute, 7));
                    } elseif (\stripos($attribute, 'Path=') === 0) {
                        $cookie->setPath(\substr($attribute, 5));
                    } elseif (\stripos($attribute, 'SameSite=') === 0) {
                        $cookie->setSameSiteRestriction(\substr($attribute, 9));
                    }
                }
            }

            return $cookie;
        } else {
            return null;
        }
    }

    /**
     * Checks whether a cookie with the specified name exists
     *
     * @param string $name the name of the cookie to check
     * @return bool whether there is a cookie with the specified name
     */
    public static function exists($name)
    {
        return isset($_COOKIE[$name]);
    }

    /**
     * Returns the value from the requested cookie or, if not found, the specified default value
     *
     * @param string $name the name of the cookie to retrieve the value from
     * @param mixed $defaultValue the default value to return if the requested cookie cannot be found
     * @return mixed the value from the requested cookie or the default value
     */
    public static function get($name, $defaultValue = null)
    {
        if (isset($_COOKIE[$name])) {
            return $_COOKIE[$name];
        } else {
            return $defaultValue;
        }
    }

    private static function isNameValid($name)
    {
        $name = (string)$name;

        // The name of a cookie must not be empty on PHP 7+ (https://bugs.php.net/bug.php?id=69523).
        if ($name !== '' || \PHP_VERSION_ID < 70000) {
            if (!\preg_match('/[=,; \\t\\r\\n\\013\\014]/', $name)) {
                return true;
            }
        }

        return false;
    }

    private static function isExpiryTimeValid($expiryTime)
    {
        return \is_numeric($expiryTime) || \is_null($expiryTime) || \is_bool($expiryTime);
    }

    private static function calculateMaxAge($expiryTime)
    {
        if ($expiryTime === 0) {
            return 0;
        } else {
            $maxAge = $expiryTime - \time();

            // The value of the `Max-Age` property must not be negative on PHP 7.0.19+ (< 7.1) and
            // PHP 7.1.5+ (https://bugs.php.net/bug.php?id=72071).
            if ((\PHP_VERSION_ID >= 70019 && \PHP_VERSION_ID < 70100) || \PHP_VERSION_ID >= 70105) {
                if ($maxAge < 0) {
                    $maxAge = 0;
                }
            }

            return $maxAge;
        }
    }

    private static function formatExpiryTime($expiryTime, $forceShow = false)
    {
        if ($expiryTime > 0 || $forceShow) {
            if ($forceShow) {
                $expiryTime = 1;
            }

            return \gmdate('D, d-M-Y H:i:s T', $expiryTime);
        } else {
            return null;
        }
    }

    private static function formatMaxAge($expiryTime, $forceShow = false)
    {
        if ($expiryTime > 0 || $forceShow) {
            return (string)self::calculateMaxAge($expiryTime);
        } else {
            return null;
        }
    }

    private static function normalizeDomain($domain = null)
    {
        // make sure that the domain is a string
        $domain = (string)$domain;

        // if the cookie should be valid for the current host only
        if ($domain === '') {
            // no need for further normalization
            return null;
        }

        // if the provided domain is actually an IP address
        if (\filter_var($domain, \FILTER_VALIDATE_IP) !== false) {
            // let the cookie be valid for the current host
            return null;
        }

        // for local hostnames (which either have no dot at all or a leading dot only)
        if (\strpos($domain, '.') === false || \strrpos($domain, '.') === 0) {
            // let the cookie be valid for the current host while ensuring maximum compatibility
            return null;
        }

        // unless the domain already starts with a dot
        if ($domain[0] !== '.') {
            // prepend a dot for maximum compatibility (e.g. with RFC 2109)
            $domain = '.' . $domain;
        }

        // return the normalized domain
        return $domain;
    }

    private static function addHttpHeader($header)
    {
        if (!\headers_sent()) {
            if (!empty($header)) {
                \header($header, false);

                return true;
            }
        }

        return false;
    }

}
