<?php

namespace Kirby\Http;

use Kirby\Cms\App;
use Kirby\Toolkit\Str;

/**
 * The `Cookie` class helps you to
 * handle cookies in your projects.
 *
 * @package   Kirby Http
 * @author    Bastian Allgeier <bastian@getkirby.com>
 * @link      https://getkirby.com
 * @copyright Bastian Allgeier
 * @license   https://opensource.org/licenses/MIT
 */
class Cookie
{
	/**
	 * Key to use for cookie signing
	 *
	 * A hardcoded default is used intentionally: there is no install step
	 * during which a random key could be generated and persisted. Kirby goes
	 * live the moment files are present on the server, so there is no defined
	 * point at which to write a one-time secret. Config files are typically
	 * kept in version control and cannot be written by the system without
	 * causing merge conflicts. No value in $_SERVER is both universally
	 * available across hosting environments and stable enough to serve as a
	 * key. Sites with security requirements must override this property with
	 * a secret random value before the first cookie is set.
	 */
	public static string $key = 'KirbyHttpCookieKey';

	/**
	 * Set a new cookie
	 *
	 * ```php
	 * // expires in 1 hour
	 * Cookie::set('mycookie', 'hello', ['lifetime' => 60]);
	 * ```
	 *
	 * @param string $key The name of the cookie
	 * @param string $value The cookie content
	 * @param array $options Array of options:
	 *                       lifetime, path, domain, secure, httpOnly, sameSite
	 * @return bool true: cookie was created,
	 *              false: cookie creation failed
	 */
	public static function set(
		string $key,
		string $value,
		array $options = []
	): bool {
		// modify CMS caching behavior
		static::trackUsage($key);

		// extract options
		$expires  = static::lifetime($options['lifetime'] ?? 0);
		$path     = $options['path']     ?? '/';
		$domain   = $options['domain']   ?? null;
		$secure   = $options['secure']   ?? false;
		$httponly = $options['httpOnly'] ?? true;
		$samesite = $options['sameSite'] ?? 'Lax';

		// add an HMAC signature of the value
		$value = static::hmac($value) . '+' . $value;

		// store that thing in the cookie global
		$_COOKIE[$key] = $value;

		// store the cookie
		return setcookie(
			$key,
			$value,
			compact('expires', 'path', 'domain', 'secure', 'httponly', 'samesite')
		);
	}

	/**
	 * Calculates the lifetime for a cookie
	 *
	 * @param int $minutes Number of minutes or timestamp
	 */
	public static function lifetime(int $minutes): int
	{
		// absolute timestamp
		if ($minutes > 1000000000) {
			return $minutes;
		}

		// minutes from now
		if ($minutes > 0) {
			return time() + ($minutes * 60);
		}

		return 0;
	}

	/**
	 * Stores a cookie forever
	 *
	 * ```php
	 * // never expires
	 * Cookie::forever('mycookie', 'hello');
	 * ```
	 *
	 * @param string $key The name of the cookie
	 * @param string $value The cookie content
	 * @param array $options Array of options:
	 *                       path, domain, secure, httpOnly
	 * @return bool true: cookie was created,
	 *              false: cookie creation failed
	 */
	public static function forever(
		string $key,
		string $value,
		array $options = []
	): bool {
		// 9999-12-31 if supported (lower on 32-bit servers)
		$options['lifetime'] = min(253402214400, PHP_INT_MAX);
		return static::set($key, $value, $options);
	}

	/**
	 * Get a cookie value
	 *
	 * ```php
	 * // sample output: 'hello' or if the cookie is not set 'peter'
	 * Cookie::get('mycookie', 'peter');
	 * ```
	 *
	 * @param string|null $key The name of the cookie
	 * @param string|null $default The default value, which should be returned
	 *                             if the cookie has not been found
	 * @return string|array|null The found value
	 */
	public static function get(
		string|null $key = null,
		string|null $default = null
	): string|array|null {
		if ($key === null) {
			return $_COOKIE;
		}

		// modify CMS caching behavior
		static::trackUsage($key);

		if ($value = $_COOKIE[$key] ?? null) {
			return static::parse($value);
		}

		return $default;
	}

	/**
	 * Checks if a cookie exists
	 */
	public static function exists(string $key): bool
	{
		return static::get($key) !== null;
	}

	/**
	 * Creates a HMAC for the cookie value
	 * Used as a cookie signature to prevent easy tampering with cookie data
	 */
	protected static function hmac(string $value): string
	{
		// prefer the option if it was set, otherwise use the value
		// set directly to this class (for backwards-compatibility)
		// or fall back to the fixed default set directly with the prop
		$key = App::instance(lazy: true)?->option('cookie.key') ?: static::$key;

		return hash_hmac('sha1', $value, $key);
	}

	/**
	 * Parses the hashed value from a cookie
	 * and tries to extract the value
	 */
	protected static function parse(string $string): string|null
	{
		// if no hash-value separator is present, we can't parse the value
		if (str_contains($string, '+') === false) {
			return null;
		}

		// extract hash and value
		$hash  = Str::before($string, '+');
		$value = Str::after($string, '+');

		// if the hash or the value is missing at all return null
		// $value can be an empty string, $hash can't be!
		if ($hash === '') {
			return null;
		}

		// compare the extracted hash with the hashed value
		// don't accept value if the hash is invalid
		if (hash_equals(static::hmac($value), $hash) !== true) {
			return null;
		}

		return $value;
	}

	/**
	 * Remove a cookie
	 *
	 * ```php
	 * // mycookie is now gone
	 * Cookie::remove('mycookie');
	 * ```
	 *
	 * @param string $key The name of the cookie
	 * @return bool true: the cookie has been removed,
	 *              false: the cookie could not be removed
	 */
	public static function remove(string $key): bool
	{
		if (isset($_COOKIE[$key]) === true) {
			unset($_COOKIE[$key]);
			return setcookie($key, '', 1, '/') && setcookie($key, false);
		}

		return false;
	}

	/**
	 * Tells the CMS responder that the response relies on a cookie and
	 * its value (even if the cookie isn't set in the current request);
	 * this ensures that the response is only cached for visitors who don't
	 * have this cookie set;
	 * https://github.com/getkirby/kirby/issues/4423#issuecomment-1166300526
	 */
	protected static function trackUsage(string $key): void
	{
		// lazily request the instance for non-CMS use cases
		App::instance(lazy: true)?->response()->usesCookie($key);
	}
}
