<?php
/**
 * @brief		URL Class
 * @author		<a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
 * @copyright	(c) Invision Power Services, Inc.
 * @license		https://www.invisioncommunity.com/legal/standards/
 * @package		Invision Community
 * @since		10 Jun 2013
 */

namespace IPS\Http;
 
/* To prevent PHP errors (extending class does not exist) revealing path */

use InvalidArgumentException;
use IPLib\Factory;
use IPLib\Range\Type;
use IPS\Application;
use IPS\cms\Pages\Page;
use IPS\Dispatcher;
use IPS\File;
use IPS\Http\Request\Curl;
use IPS\Http\Url\Exception as UrlException;
use IPS\Http\Url\Friendly;
use IPS\Http\Url\Internal;
use IPS\IPS;
use IPS\Lang;
use IPS\Request;
use IPS\Settings;
use OutOfRangeException;
use RuntimeException;
use TrueBV\Exception\OutOfBoundsException;
use TrueBV\Punycode;
use function count;
use function curl_version;
use function defined;
use function dns_get_record;
use function filter_var;
use function gethostbyname;
use function in_array;
use function intval;
use function is_array;
use function is_string;
use function ltrim;
use function preg_replace;
use function trim;
use function version_compare;
use const DNS_AAAA;
use const FILTER_VALIDATE_IP;
use const IPS\DEFAULT_REQUEST_TIMEOUT;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * URL Class
 *
 * This class represents a URL using the RFC 3986 definition of a URI (i.e. that
 * RFC is used, but objects of this class can only represent URLs, not URIs which
 * are not URLs or URNs) with the extra provision that we allow protocol-relative URLs
 *
 * @see	<a href="https://www.ietf.org/rfc/rfc3986.txt">RFC 3986</a>
 * @method csrf() Url
 */
class Url
{
	/**
	 * @brief	Automatically determine the protocol
	 */
	const PROTOCOL_AUTOMATIC = 0;

	/**
	 * @brief	Use https://
	 */
	const PROTOCOL_HTTPS = 1;

	/**
	 * @brief	Use http://
	 */
	const PROTOCOL_HTTP = 2;

	/**
	 * @brief	Use // (protoool-relative)
	 */
	const PROTOCOL_RELATIVE = 3;

	/**
	 * @brief	Use no protocol at all!
	 */
	const PROTOCOL_WITHOUT = 4;

	/**
	 * @brief	Scheme component
	 */
	const COMPONENT_SCHEME = 'scheme';

	/**
	 * @brief	Username component
	 */
	const COMPONENT_USERNAME = 'user';

	/**
	 * @brief	Password component
	 */
	const COMPONENT_PASSWORD = 'pass';

	/**
	 * @brief	Host component
	 */
	const COMPONENT_HOST = 'host';

	/**
	 * @brief	Port component
	 */
	const COMPONENT_PORT = 'port';

	/**
	 * @brief	Path component
	 */
	const COMPONENT_PATH = 'path';

	/**
	 * @brief	Querystring component
	 */
	const COMPONENT_QUERY = 'query';

	/**
	 * @brief	Query string key
	 */
	const COMPONENT_QUERY_KEY = 'queryKey';

	/**
	 * @brief	Query string value
	 */
	const COMPONENT_QUERY_VALUE = 'queryValue';

	/**
	 * @brief	Fragment component
	 */
	const COMPONENT_FRAGMENT = 'fragment';
		
	/* !Factory Methods */
	
	/**
	 * Build Internal URL
	 *
	 * @param string $queryString	The query string
	 * @param string|null $base			Key for the URL base. If NULL, defaults to current controller location
	 * @param string|null $seoTemplate	The key for making this a friendly URL
	 * @param array|string $seoTitles		The title(s) needed for the friendly URL
	 * @param int $protocol		Protocol (one of the PROTOCOL_* constants)
	 * @return	Internal|Url
	 */
	public static function internal( string $queryString, string $base=NULL, string $seoTemplate=NULL, array|string $seoTitles=array(), int $protocol = 0 ): Url|Internal
	{
		/* If we don't have a base, assume the template location */
		if ( !$base )
		{
			$base = Dispatcher::hasInstance() ? Dispatcher::i()->controllerLocation : 'front';
		}

		if( $base === 'setup' )
		{
			return new static(( Request::i()->isSecure()  ? 'https://' : 'http://' ) . $_SERVER['HTTP_HOST'] . ( $_SERVER['QUERY_STRING'] ? rtrim( mb_substr( $_SERVER['REQUEST_URI'], 0, -mb_strlen( $_SERVER['QUERY_STRING'] ) ), '?' ) : $_SERVER['REQUEST_URI'] ) . '?' . $queryString);
		}
		
		/* Front-End Friendly */
		if ( $base === 'front' and $seoTemplate and Settings::i()->use_friendly_urls )
		{
			return Friendly::friendlyUrlFromQueryString( $queryString, $seoTemplate, $seoTitles, $protocol );
		}
		/* Front-End not friendly */
		elseif ( $base === 'front' )
		{
			return Internal::createInternalFromComponents( 'front', $protocol, $queryString ? 'index.php' : '', $queryString );
		}
		/* Admin */
		elseif ( $base === 'admin' or $base === 'admin_redirect' )
		{
			return Internal::createInternalFromComponents(
				'admin',
				$protocol,
				'admin/',
				static::convertQueryAsStringToArray($queryString)
			);
		}
		/* None */
		else
		{
			return static::createFromString(static::baseUrl($protocol) . $queryString, FALSE);
		}
	}
	
	/**
	 * Build External URL
	 *
	 * @param string $url
	 * @return    Url
	 * @throws	InvalidArgumentException
	 */
	public static function external( string $url ): Url
	{
		return new static($url, TRUE);
	}
	
	/**
	 * Build IPS-External URL
	 *
	 * @param string $url
	 * @return    Url
	 */
	final public static function ips( string $url ): Url
	{			
		return new static(\IPS\Settings::i()->base_url);
	}
	final public static function ips5( string $url ): Url
	{			
		return new static(\IPS\Settings::i()->base_url);
	}
	/**
	 * Create from components - all arguments should be UNENCODED
	 *
	 * @param string $host		Host
	 * @param string|null $scheme		Scheme (NULL for protocol-relative)
	 * @param string|null $path		Path
	 * @param array|string|null $query		Query
	 * @param int|null $port		Port
	 * @param string|null $username	Username
	 * @param string|null $password	Password
	 * @param string|null $fragment	Fragment
	 * @return    Url
	 */
	public static function createFromComponents( string $host, string $scheme = NULL, string $path = NULL, array|string $query = NULL, int $port = NULL, string $username = NULL, string $password = NULL, string $fragment = NULL ): Url
	{
		$obj = new static('');
		
		$obj->data[ static::COMPONENT_SCHEME ] = $scheme;
		$obj->data[ static::COMPONENT_HOST ] = $host;
		$obj->data[ static::COMPONENT_PORT ] = $port;
		$obj->data[ static::COMPONENT_USERNAME ] = $username;
		$obj->data[ static::COMPONENT_PASSWORD ] = $password;
		$obj->data[ static::COMPONENT_PATH ] = $path;
		$obj->data[ static::COMPONENT_FRAGMENT ] = $fragment;

		if ( is_array( $query ) )
		{
			$obj->data[ static::COMPONENT_QUERY ] = static::convertQueryAsArrayToString($query);
			$obj->queryString = $query;
		}
		elseif ( is_string( $query ) )
		{
			$obj->data[ static::COMPONENT_QUERY ] = $query;
			$obj->queryString = static::convertQueryAsStringToArray($query);
		}
						
		$obj->reconstructUrlFromData();
		
		return $obj;
	}
	
	/**
	 * Create from string
	 * This method is somewhat performance-intensive and should only be used either for creating friendly URLs, or if it is not known
	 * if the URL will be internal or external. If you know the URL will be external, use new \IPS\Http\Url( ... )
	 *
	 * @param string $url				A valid URL as per our definition (see phpDoc on class)
	 * @param bool $couldBeFriendly	If the URL is known to not be friendly, FALSE can be passed here to save the performance implication of checking the URL
	 * @param bool $autoEncode			If true, any invalid components will be automatically encoded rather than an exception thrown - useful if the entire link is user-provided
	 * @return    Url
	 * @throws    UrlException
	 */
	public static function createFromString(string $url, bool $couldBeFriendly=TRUE, bool $autoEncode=FALSE ): Url
	{
		/* Decode it */
		$components = static::componentsFromUrlString($url, $autoEncode);
		
		/* Is it internal? */
		$baseUrlComponents = static::componentsFromUrlString(static::baseUrl());

		if ( $components[ static::COMPONENT_HOST ] === $baseUrlComponents[ static::COMPONENT_HOST ] and
			$components[ static::COMPONENT_USERNAME ] === $baseUrlComponents[ static::COMPONENT_USERNAME ] and
			$components[ static::COMPONENT_PASSWORD ] === $baseUrlComponents[ static::COMPONENT_PASSWORD ] and
			$components[ static::COMPONENT_PORT ] === $baseUrlComponents[ static::COMPONENT_PORT ] and
			mb_substr( $components[ static::COMPONENT_PATH ], 0, mb_strlen( $baseUrlComponents[ static::COMPONENT_PATH ] ) ) === $baseUrlComponents[ static::COMPONENT_PATH ]
		)
		{		
			$pathFromBaseUrl = mb_substr( $components[ static::COMPONENT_PATH ], mb_strlen( $baseUrlComponents[ static::COMPONENT_PATH ] ) );
			
			/* Admin */
			if ( preg_match( '/^' . preg_quote( 'admin', '/' ) . '($|\/)/', $pathFromBaseUrl ) )
			{
				$return = Internal::createInternalFromComponents( 'admin', $components[ static::COMPONENT_SCHEME ], $pathFromBaseUrl, $components[ static::COMPONENT_QUERY ], $components[ static::COMPONENT_FRAGMENT ] );
			}
			/* Front-End: Not friendly or unrewritten friendly */
			elseif ( !$pathFromBaseUrl or $pathFromBaseUrl === 'index.php' )
			{
				$queryString = Url::convertQueryAsArrayToString( $components[ static::COMPONENT_QUERY ] );
				$potentialFurl = trim( mb_substr( $queryString, 0, mb_strpos( $queryString, '&' ) ?: NULL ), '/' );
				if ( !$pathFromBaseUrl or !( $couldBeFriendly and $return = Friendly::createFriendlyUrlFromComponents( $components, $potentialFurl ) ) )
				{
					$return = Internal::createInternalFromComponents( 'front', $components[ static::COMPONENT_SCHEME ], $pathFromBaseUrl, $components[ static::COMPONENT_QUERY ], $components[ static::COMPONENT_FRAGMENT ] );
				}
			}
			/* Front-End: Rewritten friendly */
			elseif (  !( $couldBeFriendly and $return = Friendly::createFriendlyUrlFromComponents( $components, rtrim( mb_substr( $components[ static::COMPONENT_PATH ], mb_strlen( $baseUrlComponents[ static::COMPONENT_PATH ] ) ), '/' ) ) ) )
			{
				$return = Internal::createInternalFromComponents( 'none', $components[ static::COMPONENT_SCHEME ], $pathFromBaseUrl, $components[ static::COMPONENT_QUERY ], $components[ static::COMPONENT_FRAGMENT ] );
			}
		}
		/* Nope, external */
		else
		{
			$return = Url::createFromComponents( $components['host'], $components['scheme'], $components['path'], $components['query'], $components['port'], $components['user'], $components['pass'], $components['fragment'] );
		}

		/* If we are in setup, stop here */
		if( Dispatcher::hasInstance() and Dispatcher::i()->controllerLocation == 'setup' )
		{
			return $return;
		}

		/* If the normal handling doesn't recognise it as an internal URL and we
			have a gateway file, check that */
		if ( Application::appIsEnabled( 'cms' ) and !( $return instanceof Internal ) and Settings::i()->cms_root_page_url )
		{
			/* Decode it */
			$components = static::componentsFromUrlString( $url, $autoEncode );

			/* Is it underneath the gateway? */
			$gatewayUrlComponents = static::componentsFromUrlString( Settings::i()->cms_root_page_url  );
			if ( $components[ static::COMPONENT_HOST ] === $gatewayUrlComponents[ static::COMPONENT_HOST ] and
				$components[ static::COMPONENT_USERNAME ] === $gatewayUrlComponents[ static::COMPONENT_USERNAME ] and
				$components[ static::COMPONENT_PASSWORD ] === $gatewayUrlComponents[ static::COMPONENT_PASSWORD ] and
				$components[ static::COMPONENT_PORT ] === $gatewayUrlComponents[ static::COMPONENT_PORT ] and
				mb_substr( $components[ static::COMPONENT_PATH ], 0, mb_strlen( $gatewayUrlComponents[ static::COMPONENT_PATH ] ) ) === $gatewayUrlComponents[ static::COMPONENT_PATH ]
			)
			{
				$pathFromGatewayUrl = mb_substr( $components[ static::COMPONENT_PATH ], mb_strlen( $gatewayUrlComponents[ static::COMPONENT_PATH ] ) );
				$fallback = FALSE;
				if ( !$pathFromGatewayUrl or $pathFromGatewayUrl === 'index.php' )
				{
					if ( !$pathFromGatewayUrl )
					{
						$fallback = TRUE;
					}
					$queryString = Url::convertQueryAsArrayToString( $components[ static::COMPONENT_QUERY ] );
					$pathFromGatewayUrl = trim( mb_substr( $queryString, 0, mb_strpos( $queryString, '&' ) ?: NULL ), '/' );
				}

				/* Try to find a page */
				$page = NULL;
				try
				{
					$page = Page::loadFromPath( $pathFromGatewayUrl );
					$return = Friendly::createFromComponents( $components[ static::COMPONENT_HOST ], $components[ static::COMPONENT_SCHEME ], $components[ static::COMPONENT_PATH ], $components[ static::COMPONENT_QUERY ], $components[ static::COMPONENT_PORT ], $components[ static::COMPONENT_USERNAME ], $components[ static::COMPONENT_PASSWORD ], $components[ static::COMPONENT_FRAGMENT ] )
						->setFriendlyUrlData( 'content_page_path', array( $pathFromGatewayUrl ), array( 'path' => $pathFromGatewayUrl ), $pathFromGatewayUrl );
				}
					/* Couldn't find one? Don't accept responsibility, unless there was no $pathFromGatewayUrl and this is the gateway URL */
				catch ( OutOfRangeException $e )
				{
					if ( $fallback and (string) $return->stripQueryString() === Settings::i()->cms_root_page_url )
					{
						try
						{
							$page = Page::loadFromPath( '' );
							$return = Friendly::createFromComponents( $components[ static::COMPONENT_HOST ], $components[ static::COMPONENT_SCHEME ], $components[ static::COMPONENT_PATH ], $components[ static::COMPONENT_QUERY ], $components[ static::COMPONENT_PORT ], $components[ static::COMPONENT_USERNAME ], $components[ static::COMPONENT_PASSWORD ], $components[ static::COMPONENT_FRAGMENT ] )
								->setFriendlyUrlData( 'content_page_path', array( '' ), array( 'path' => '' ) );
						}
						catch (OutOfRangeException $e ) { }
					}
				}
			}
		}

		/* Return */
		return $return;
	}
		
	/* !Instance */
	
	/**
	 * @brief	URL, with all components appropriately encoded
	 */
	public ?string $url = NULL;
	
	/**
	 * @brief	All the different components, decoded
	 */
	public array $data = array(
		self::COMPONENT_SCHEME => NULL,
		self::COMPONENT_HOST => NULL,
		self::COMPONENT_PORT => NULL,
		self::COMPONENT_USERNAME => NULL,
		self::COMPONENT_PASSWORD => NULL,
		self::COMPONENT_PATH => NULL,
		self::COMPONENT_QUERY => NULL,
		self::COMPONENT_FRAGMENT => NULL,
	);
		
	/**
	 * @brief	Query string as decoded array
	 */
	public mixed $queryString = array();

	/**
	 * @brief	Hidden Query String Parameters
	 */
	public array $hiddenQueryString = array();
		
	/**
	 * Constructor
	 *
	 * @param string|null $url		A valid URL as per our definition (see phpDoc on class) or NULL if being called by createFromComponents()
	 * @param bool $autoEncode	If true, any invalid components will be automatically encoded rather than an exception thrown - useful if the entire link is user-provided
	 * @return	void
	 * @throws    UrlException
	 *	@li	INVALID_URL			The URL did not start with // to indicate relative protocol or contain ://
	 *	@li	INVALID_SCHEME		The scheme was invalid
	 *	@li	INVALID_USERNAME	The username was invalid
	 *	@li	INVALID_PASSWORD	The password was invalid
	 *	@li	INVALID_HOST		The host name was invalid
	 *	@li	INVALID_PATH		The path was invalid
	 *	@li	INVALID_QUERY		The query was invalid
	 *	@li	INVALID_FRAGMENT	The fragment was invalid
	 */
	public function __construct( string $url = NULL, bool $autoEncode=FALSE )
	{
		if ( $url )
		{
			/* Set the URL */
			$this->url = $url;
			
			/* Set the components */
			$this->data = static::componentsFromUrlString($url, $autoEncode);
			$this->queryString = $this->data['query'];
			$this->data['query'] = static::convertQueryAsArrayToString($this->queryString);
		}
	}
	
	/**
	 * Adjust scheme
	 *
	 * @param string|null $scheme	Scheme (NULL for protocol-relative)
	 * @return    Url
	 */
	public function setScheme( ?string $scheme ): Url
	{
		$obj = clone $this;
		$obj->data[ static::COMPONENT_SCHEME ] = $scheme;
		$obj->reconstructUrlFromData();
		return $obj;
	}
	
	/**
	 * Adjust host
	 *
	 * @param string $host	Host
	 * @return    Url
	 */
	public function setHost( string $host ): Url
	{
		$obj = clone $this;
		$obj->data[ static::COMPONENT_HOST ] = $host;
		$obj->reconstructUrlFromData();
		return $obj;
	}
	
	/**
	 * Adjust path
	 *
	 * @param string|null $path Path
	 * @return    Url
	 */
	public function setPath( ?string $path ): Url
	{
		$obj = clone $this;
		$obj->data[ static::COMPONENT_PATH ] = $path;
		$obj->reconstructUrlFromData();
		return $obj;
	}
	
	/**
	 * Adjust Query String
	 *
	 * @param array|string $keyOrArray	Key, or array of key/value pairs
	 * @param array|string|null $value		Value, or NULL if $key is an array
	 * @return    Url
	 */
	public function setQueryString( array|string $keyOrArray, array|string|null $value=NULL ): Url
	{
		$newQueryArray = $this->queryString;
		
		if ( is_array( $keyOrArray ) )
		{
			foreach ( $keyOrArray as $k => $v )
			{
				if ( $v === NULL )
				{
					unset( $newQueryArray[ $k ] );
				}
				else
				{
					$newQueryArray[ $k ] = $v;
				}
			}
		}
		else
		{
			if ( $value === NULL )
			{
				unset( $newQueryArray[ $keyOrArray ] );
			} 
			else
			{
				$newQueryArray[ $keyOrArray ] = $value;
			}
		}
		
		$obj = clone $this;
		$obj->data[ static::COMPONENT_QUERY ] = static::convertQueryAsArrayToString($newQueryArray);
		$obj->queryString = $newQueryArray;
		$obj->reconstructUrlFromData();
		return $obj;
	}
	
	/**
	 * Reset the $url property after changing data
	 *
	 * @return	void
	 */
	protected function reconstructUrlFromData() : void
	{
		/* Scheme */
		$scheme = '';
		if ( $this->data[ static::COMPONENT_SCHEME ] )
		{
			$scheme = $this->data[ static::COMPONENT_SCHEME ] . '://';
		}
		else
		{
			$scheme = '//';
		}
		
		/* Username and password */
		$usernameAndPassword = '';
		if ( $this->data[ static::COMPONENT_USERNAME ] )
		{
			$usernameAndPassword = static::encodeComponent( static::COMPONENT_USERNAME, $this->data[ static::COMPONENT_USERNAME ] );
			if ( $this->data[ static::COMPONENT_PASSWORD ] )
			{
				$usernameAndPassword .= ':' . static::encodeComponent( static::COMPONENT_PASSWORD, $this->data[ static::COMPONENT_PASSWORD ] );
			}
			$usernameAndPassword .= '@';
		}
		
		/* Host */
		$host = static::encodeComponent( static::COMPONENT_HOST, $this->data[ static::COMPONENT_HOST ] );
		
		/* Port */
		$port = '';
		if ( $this->data[ static::COMPONENT_PORT ] )
		{
			$port = ':' . intval( $this->data[ static::COMPONENT_PORT ] );
		}
		
		/* Path */
		$path = '';
		if ( $this->data[ static::COMPONENT_PATH ] )
		{
			$path = '/' . ltrim( static::encodeComponent( static::COMPONENT_PATH, $this->data[ static::COMPONENT_PATH ] ), '/' );
		}
		
		/* Query String */
		$queryString = '';
		if ( $this->data[ static::COMPONENT_QUERY ] )
		{
			if ( empty( $path ) )
			{
				$path = '/';
			}
			
			$queryString = '?' . static::convertQueryAsArrayToString($this->queryString, TRUE);
		}
		
		/* Fragment */
		$fragment = '';
		if ( $this->data[ static::COMPONENT_FRAGMENT ] )
		{
			$fragment = '#' . static::encodeComponent( static::COMPONENT_FRAGMENT, $this->data[ static::COMPONENT_FRAGMENT ] );
		}
		
		/* Put it all together */
		$this->url = $scheme . $usernameAndPassword . $host . $port . $path . $queryString . $fragment;
	}
	
	/**
	 * Strip Query String
	 *
	 * @param array|string|null $keys	The key(s) to strip - if omitted, entire query string is wiped
	 * @return    Url
	 */
	public function stripQueryString( array|string $keys=NULL ): Url
	{
		$newQueryArray = array();

		if( $keys !== NULL )
		{
			if( !is_array( $keys ) )
			{
				$keys = array( $keys => $keys );
			}

			$newQueryArray = array_diff_key( $this->queryString, array_combine( array_values( $keys ), array_values( $keys ) ) );
		}

		return static::createFromComponents( $this->data[ static::COMPONENT_HOST ], $this->data[ static::COMPONENT_SCHEME ], $this->data[ static::COMPONENT_PATH ], $newQueryArray, $this->data[ static::COMPONENT_PORT ], $this->data[ static::COMPONENT_USERNAME ], $this->data[ static::COMPONENT_PASSWORD ], $this->data[ static::COMPONENT_FRAGMENT ] );
	}
	
	/**
	 * Adjust Fragment
	 *
	 * @param string $fragment	Fragment
	 * @return    Url
	 */
	public function setFragment( string $fragment ): Url
	{
		return static::createFromComponents( $this->data[ static::COMPONENT_HOST ], $this->data[ static::COMPONENT_SCHEME ], $this->data[ static::COMPONENT_PATH ], $this->data[ static::COMPONENT_QUERY ], $this->data[ static::COMPONENT_PORT ], $this->data[ static::COMPONENT_USERNAME ], $this->data[ static::COMPONENT_PASSWORD ], $fragment );
	}

	/**
	 * Make safe for ACP
	 *
	 * @param bool $resource
	 * @return    Url
	 * @deprecated    No longer needed as of 4.5, ACP URLs no longer have the session ID in the URL
	 */
	public function makeSafeForAcp( bool $resource=FALSE ) : Url
	{
		return $this;
		
		//return static::internal( "app=core&module=system&controller=redirect", 'front' )->setQueryString( array(
		//	'url'		=> (string) $this,
		//	'key'		=> hash_hmac( "sha256", (string) $this, \IPS\Settings::i()->site_secret_key . 'r' ),
		//	'resource'	=> $resource
		//) );
	}

	/**
	 * Make a HTTP Request to a resource that we cannot trust, so we need to check more carefully
	 * We want to avoid any SSRF vulnerabilities, so we only allow certain protocols and ensure we're not loading from a private network
	 * @note https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
	 *
	 * This method ensures the following:
	 *
	 * The requested scheme is either http or https only (no ftp, file, etc)
	 * Redirects are not followed (so a site cannot try and redirect to a private network IP)
	 * That the port is either 80 or 443
	 * That IPv6 ranges are not allowed '[::]'
	 * That hex/binary/oct domain names are not allowed
	 * That common localhost and internal domains/IPs are not used
	 * That the IP address is not a private or local network IP
	 * That the host isn't the same as the current domain
	 *
	 * @param int $timeout Timeout
	 * @param int|null $bytesToGet Number of bytes to get. null means get everything you greedy piggie
	 * @return    Curl
	 */
	public function requestUntrusted( int $timeout=2, int|null $bytesToGet=50000 ): Curl
	{
		$allowedProtocols = array( 'http', 'https' );
		$allowedContentTypes = array( 'text/html' );

		/* We need a scheme */
		if ( ! isset( $this->data['scheme'] ) )
		{
			throw new RuntimeException( 'NO_SCHEME' );
		}

		/* We only allow port 80 and 443 */
		if ( isset( $this->data['port'] ) and ! in_array( $this->data['port'], [ 80, 443 ] ) )
		{
			throw new RuntimeException( 'INVALID_PORT' );
		}

		/* To avoid security issues from using file:// telnet:// etc. We reject everything that is not a http(s)? scheme */
		if( $this->data['scheme'] AND !in_array( $this->data['scheme'], $allowedProtocols ) OR ! preg_match( "#^http(s)?://#", (string) $this ) )
		{
			throw new RuntimeException( mb_strtoupper( $this->data['scheme'] ) . '_SCHEME_NOT_PERMITTED' );
		}

		/* Disallow things like http://[::]:port and http://[0:0:0:0:0:ffff:127.0.0.1] */
		if ( strpos( $this->data['host'], '[' ) !== FALSE )
		{
			throw new RuntimeException( 'INVALID_IPV6_HOST' );
		}

		/* Block hex/decimal/octal/binary hostnames as a precaution. */
		if(
			mb_substr( ltrim( $this->data['host'] ), 0, 2 ) == '0x' OR 															// Hex
			preg_replace( "/[01\.]/", '', trim( $this->data['host'] ) ) === '' OR												// Binary
			( mb_substr( ltrim( $this->data['host'] ), 0, 1 ) == '0' AND mb_strpos( $this->data['host'], '.' ) === FALSE ) OR	// Octal
			preg_replace( "/[0-9]/", '', trim( $this->data['host'] ) ) === ''													// Decimal
		)
		{
			throw new RuntimeException( 'HEX|BINARY|OCTAL|DECIMAL_HOST_NAMES_NOT_PERMITTED' );
		}
		
		/* Check for obvious internal domains and IP addresses */
		if ( in_array( $this->data['host'], [
			'localhost',
			'instance-data',   # aws
			'169.254.169.254', # aws, google, azure
			'192.0.0.192', # oracle
			'100.100.100.200', #Alibaba
			'metadata.google.internal', # google
			'metadata' #google
		] ) )
		{
			throw new RuntimeException( 'HOST_NOT_PERMITTED' );
		}

		$ip = null;
		if ( filter_var( $this->data['host'], FILTER_VALIDATE_IP ) !== false )
		{
			/* The host is actually an IPv4 or IPv6, so there's no need to do any DNS look-ups */
			$ip = $this->data['host'];

			if ( static::isPrivateIp( $ip ) )
			{
				throw new RuntimeException( 'LOCAL_AND_PRIVATE_NETWORKS_NOT_PERMITTED' );
			}
		}
		else if ( $ips = @gethostbynamel( $this->data['host'] ) )
		{
			/*
			 *  Let's try to see if it's an IPv4 address. Loop through them all to ensure we are not subject to a validation attack
			 * (@link https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html#application-layer_1)
			 */
			foreach ( $ips as $possibleIp )
			{
				if ( static::isPrivateIp( $possibleIp ) )
				{
					throw new RuntimeException( 'LOCAL_AND_PRIVATE_NETWORKS_NOT_PERMITTED' );
				}

				$ip = $possibleIp;
			}
		}
		else if ( $ips = @dns_get_record( $this->data['host'], DNS_AAAA ) )
		{
			foreach ( $ips as $possibleIp )
			{
				if ( static::isPrivateIp( $possibleIp['ipv6'] ) )
				{
					throw new RuntimeException( 'LOCAL_AND_PRIVATE_NETWORKS_NOT_PERMITTED' );
				}

				$ip = $possibleIp['ipv6'];
			}
		}

		/* We really need an IP address */
		if ( ! $ip )
		{
			throw new RuntimeException( 'COULD_NOT_RESOLVE_HOST' );
		}

		/* Don't allow attempts to load files on the current domain */
		$baseUrlHostname = static::internal('')->data['host'];
		if ( preg_match( "#\." . preg_quote( $baseUrlHostname ) . "$#", '.' . $this->data['host'] ) or preg_match( "#\." . preg_quote( $this->data['host'] ) . "$#", '.' . $baseUrlHostname ) )
		{
			throw new RuntimeException( 'SAME_DOMAIN_NOT_PERMITTED' );
		}

		/* Set a timeout */
		if( $timeout === null )
		{
			$timeout = DEFAULT_REQUEST_TIMEOUT;
		}

		/* Use cURL - We require 7.36 or higher because older versions can't handle chunked encoding properly */
		$version = curl_version();
		if( version_compare( $version['version'], '7.36', '>=' ) )
		{
			$requestObj	= new Curl( $this, $timeout, '1.1', true, $allowedProtocols, $allowedContentTypes, true, $bytesToGet );
		}

		if( !isset( $requestObj ) )
		{
			throw new RuntimeException( 'CURL_NOT_AVAILABLE' );
		}

		/* Set a default user-agent (some services, e.g. spotify, block requests without one but it's good to do so anyway) */
		$requestObj->setHeaders( array( 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36' ) );
		$requestObj->setHeaders( array( 'Accept-Language' => $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? Lang::load( Lang::defaultLanguage() )->bcp47() ) );

		/* Return */
		return $requestObj;
	}

	/**
	 * Check and IP to see if it is a private network
	 * @throws RuntimeException
	 * @param string $ip
	 * @return bool
	 */
	protected static function isPrivateIp( string $ip ): bool
	{
		/* Use a robust IP library to check ipv4 and ipv6 ip addresses [https://github.com/mlocati/ip-lib] */
		IPS::$PSR0Namespaces['IPLib'] = \IPS\ROOT_PATH .'/system/3rd_party/IpLib';
		$address = Factory::parseAddressString( $ip );

		if ( ! $address )
		{
			throw new RuntimeException( 'COULD_NOT_PARSE_IP' );
		}

		if ( $address->getRangeType() !== Type::T_PUBLIC )
		{
			return true;
		}

		return false;
	}

	/**
	 * Make a HTTP Request
	 *
	 * @param int|null $timeout				Timeout
	 * @param string|null $httpVersion			HTTP Version
	 * @param bool|int $followRedirects		Automatically follow redirects? If a number is provided, will follow up to that number of redirects
	 * @param array|null $allowedProtocols		Protocols allowed (if NULL we default to array( 'http', 'https', 'ftp', 'scp', 'sftp', 'ftps' ))
	 * @return    Curl
	 */
	public function request( int $timeout=null, string $httpVersion=null, bool|int $followRedirects=5, array $allowedProtocols=NULL ): Curl
	{
		$allowedProtocols = $allowedProtocols ?: array( 'http', 'https', 'ftp', 'scp', 'sftp', 'ftps' );

		/* Check the scheme is valid. Some areas accept user-submitted information to create a Url object. To avoid
			security issues from using file:// telnet:// etc. We reject everything not used by the suite here */
		if( isset( $this->data['scheme'] ) AND !in_array( $this->data['scheme'], $allowedProtocols ) )
		{
			throw new RuntimeException( mb_strtoupper( $this->data['scheme'] ) . '_SCHEME_NOT_PERMITTED' );
		}
		
		/* Set a timeout */
		if( $timeout === null )
		{
			$timeout = DEFAULT_REQUEST_TIMEOUT;
		}

		/* Use cURL - We require 7.36 or higher because older versions can't handle chunked encoding properly */
		$version = curl_version();
		if( version_compare( $version['version'], '7.36', '>=' ) )
		{
			$requestObj	= new Curl( $this, $timeout, $httpVersion, $followRedirects, $allowedProtocols );
		}

		if( !isset( $requestObj ) )
		{
			throw new RuntimeException( 'CURL_NOT_AVAILABLE' );
		}

		/* Set a default user-agent (some services, e.g. spotify, block requests without one but it's good to do so anyway) */
		$requestObj->setHeaders( array( 'User-Agent' => 'Invision Community 5' ) );
		
		/* Return */
		return $requestObj;
	}
	
	/**
	 * Import as file
	 *
	 * @param string $storageExtension	The extension which specified the storage location to use
	 * @return	File
	 * @throws	RuntimeException
	 */
	public function import( string $storageExtension ): File
	{
		$response = $this->request()->get();

		/* We should not attempt to "import" 404, 403, 500, etc. responses */
		if( (int) $response->httpResponseCode !== 200 )
		{
			throw new RuntimeException( "COULD_NOT_IMPORT" );
		}

		return File::create( $storageExtension, basename( $this->data[ static::COMPONENT_PATH ] ), (string) $response );
	}
			
	/**
	 * To string
	 *
	 * @return	string
	 */
	public function __toString()
	{
		return (string) $this->url;
	}
	
	/* !Encoding and Decoding */
	
	/**
	 * @brief	Allowed characters for the different components, everything apart from schemes and ports can also contain percent-encoded characters
	 */
	protected static array $allowedCharacters = array(
		/* A scheme must is allowed to contain letters, digits, plus ("+"), period ("."), or hyphen ("-"). */
		self::COMPONENT_SCHEME => 'A-Za-z0-9\+\.\-',
		/* A username is allowed to contain Unreserved Characters and Subcomponent Delimiters */
		self::COMPONENT_USERNAME => 'A-Za-z0-9\-\._~!\$&\'\(\)\*\+,;=',
		/* A password is allowed to contain Unreserved Characters, Subcomponent Delimiters, and ":" */
		self::COMPONENT_PASSWORD => 'A-Za-z0-9\-\._~!\$&\'\(\)\*\+,;=:',
		/* Hosts can be several different things (IP lireral, IPv4 address or registered name), but for the sake of validation it comes down to Unreserved Characters and Subcomponent delimiters */
		self::COMPONENT_HOST => 'A-Za-z0-9\-\._~!\$&\'\(\)\*\+,;=',
		/* Ports must be numeric */
		self::COMPONENT_PORT => '0-9',
		/* A path is made up of path segments separated by a single /. A path segment is allowed to contain Unreserved Characters, Percent-Encoded Characters, Subcomponent Delimiters, plus ":", "@" */
		self::COMPONENT_PATH => 'A-Za-z0-9\-\._~!\$&\'\(\)\*\+,;=:@\/',
		/* A query string can contain Unreserved Characters, Subcomponent Delimiters, plus ":", "@", "/" and "?".
			While "[" and "]" are not in the RFC, common convention is to use them for multi-dimensional key-value pairs, so they are also included which should be fine as square brackets are
			 only part of the general delimiters because of their use in the host for IPv6 characters - they have no special meaning in the query string or beyond
			While "{" and "}" are also not in the RFC, we need to allow them without throwing an error because older versions allowed them and otherwise the upgrader will throw an error half-way through
		*/
		self::COMPONENT_QUERY => 'A-Za-z0-9\-\._~!\$&\'\(\)\*\+,;=:@\/\?\[\]\{\}',
		/* In a query string which is using the "k1=v1&k2=v2" convention, the keys  can contain anything a query string can contain except & and = */
		self::COMPONENT_QUERY_KEY => 'A-Za-z0-9\-\._~!\$\'\(\)\*\+,;:@\/\?\[\]',
		/* In a query string which is using the "k1=v1&k2=v2" convention, the value  can contain anything a query string can contain except & */
		self::COMPONENT_QUERY_VALUE => 'A-Za-z0-9\-\._~!\$\'\(\)\*\+,;=:@\/\?\[\]',
		/* A fragment can contain Unreserved Characters, Subcomponent Delimiters, plus ":", "@", "/" and "?" */
		self::COMPONENT_FRAGMENT => 'A-Za-z0-9\-\._~!\$&\'\(\)\*\+,;=:@\/\?',
	);
	
	/**
	 * Validate a component
	 *
	 * @param string $component	One of the COMPONENT_* constants
	 * @param string $value		The value
	 * @return	bool
	 */
	public static function validateComponent( string $component, string $value ): bool
	{
		/* Get the allowed characters */
		$allowedCharacters = static::$allowedCharacters[ $component ];
		
		/* These ones can also include percent-encoded characters and non-latin characters */
		if ( !in_array( $component, array( static::COMPONENT_SCHEME, static::COMPONENT_PORT ) ) )
		{
			$regex = '(' . '(%[A-Fa-z0-9]{2})|' . '[' . $allowedCharacters . ']\X*' . ')*';
		}
		else
		{
			$regex = '[' . $allowedCharacters . ']*';
		}
		
		/* Schemes must begin with a letter */
		if ( $component === static::COMPONENT_SCHEME )
		{
			$regex = '[A-Za-z]' . $regex;
		}
		
		/* Return */
		return (bool) preg_match( '/^' . $regex . '$/', $value );
	}
	
	/**
	 * Percent-Encode a component
	 *
	 * @param string $component			One of the COMPONENT_* constants
	 * @param string $value				The value
	 * @return	string
	 * @throws	InvalidArgumentException
	 */
	public static function encodeComponent( string $component, string $value ): string
	{
		/* These ones cannot be percent-encoded */
		if ( in_array( $component, array( static::COMPONENT_SCHEME, static::COMPONENT_PORT ) ) )
		{
			throw new InvalidArgumentException;
		}
				
		/* Get the allowed characters */
		$allowedCharacters = static::$allowedCharacters[ $component ];
		
		/* Do it */
		$return = preg_replace_callback( '/[^' . $allowedCharacters . ']/i', function( $matches )
		{
			return rawurlencode( $matches[0] );
		}, $value );
		
		/* While + is technically valid in query strings, PHP will interpret it as a space in $_GET data if
			it isn't encoded (for example, if you use the internal redirector for a URL with a + in it - like
			a Google+ profile - when that hits PHP, it will convert it to a space, making the URL invalid.
			There is no downside to percent-encoding any particular character even if it doesn't technically
			need to be, so to avoid this issue, we'll just encode it */
		if ( in_array( $component, array( static::COMPONENT_QUERY, static::COMPONENT_QUERY_KEY, static::COMPONENT_QUERY_VALUE ) ) )
		{
			$return = str_replace( '+', '%2B', $return );
		}
		
		/* Return */
		return $return;
	}
	
	/**
	 * Un-Percent-Encode a component
	 *
	 * @param	string	$component	One of the COMPONENT_* constants
	 * @param	string	$value		The value
	 * @return	string
	 * @throws	InvalidArgumentException
	 */
	public static function decodeComponent( string $component, string $value ): string
	{
		return rawurldecode( $value );
	}
	
	/* !Utilities */
	
	/**
	 * Return the base URL
	 *
	 * @param mixed $protocol		Protocol (one of the PROTOCOL_* constants)
	 * @return	string
	 */
	public static function baseUrl( mixed $protocol = 0 ): string
	{
		/* Get the base URL */
		$url = Settings::i()->base_url;
		
		/* Adjust the protocol */
		if ( $protocol )
		{
			switch ( $protocol )
			{
				case static::PROTOCOL_HTTPS:
					$url = 'https://' . mb_substr( $url, mb_strpos( $url, '://' ) + 3 );
					break;
					
				case static::PROTOCOL_HTTP:
					$url = 'http://' . mb_substr( $url, mb_strpos( $url, '://' ) + 3 );
					break;
					
				case static::PROTOCOL_RELATIVE:
					$url = '//' . mb_substr( $url, mb_strpos( $url, '://' ) + 3 );
					break;
				case static::PROTOCOL_WITHOUT:
					$url =  mb_substr( $url, mb_strpos( $url, '://' ) + 3 );
					break;
			}
		}
		
		/* Add a trailing slash */
		if ( mb_substr( $url, -1 ) !== '/' )
		{
			$url .= '/';
		}
		
		/* Return */
		return $url;
	}
	
	/**
	 * Add a referrer to the URL
	 *
	 * @param	string	$url		The URL.
	 * @return	Url
	 */
	public function addRef( string $url ): Url
	{
		return $this->setQueryString( 'ref', base64_encode( $url ) );
	}
	
	/**
	 * @brief Punycode object
	 */
	protected static ?Punycode $punycode = NULL;
	
	/**
	 * Convert a full URL into it's components
	 *
	 * @param string|null $url		A valid URL as per our definition (see phpDoc on class) or NULL if being called by createFromComponents()
	 * @param bool $autoEncode		If true, any invalid components will be automatically encoded rather than an exception thrown - useful if the entire link is user-provided
	 * @return	array
	 * @throws    UrlException
	 *	@li	INVALID_URL			The URL did not start with // to indicate relative protocol or contain ://
	 *	@li	INVALID_SCHEME		The scheme was invalid
	 *	@li	INVALID_USERNAME	The username was invalid
	 *	@li	INVALID_PASSWORD	The password was invalid
	 *	@li	INVALID_HOST		The host name was invalid
	 *	@li	INVALID_PATH		The path was invalid
	 *	@li	INVALID_QUERY		The query was invalid
	 *	@li	INVALID_FRAGMENT	The fragment was invalid
	 */
	protected static function componentsFromUrlString( ?string $url, bool $autoEncode=FALSE ): array
	{
		/* Init */
		$return = array(
			static::COMPONENT_SCHEME => NULL,
			static::COMPONENT_HOST => NULL,
			static::COMPONENT_PORT => NULL,
			static::COMPONENT_USERNAME => NULL,
			static::COMPONENT_PASSWORD => NULL,
			static::COMPONENT_PATH => NULL,
			static::COMPONENT_QUERY => array(),
			static::COMPONENT_FRAGMENT => NULL,
		);
		
		/* If the URL doesn't start with the protocol-relative marker ("//"), it needs a scheme: */
		if ( mb_substr( $url, 0, 2 ) !== '//' )
		{
			/* Work out where the :// is */
			$colonAndDoubleForwardSlashPosition = mb_strpos( $url, '://' );
			if ( $colonAndDoubleForwardSlashPosition === FALSE )
			{
				if ( $autoEncode )
				{
					$scheme = 'http';
				}
				else
				{
					throw new UrlException('INVALID_URL');
				}
			}
			else
			{
				$scheme = mb_substr( $url, 0, $colonAndDoubleForwardSlashPosition );

				/* Take the scheme off the URL for the rest of of our processing */
				$url = mb_substr( $url, mb_strlen( $scheme ) + 3 );
			}
			
			/* Validate the scheme */
			if ( !static::validateComponent( static::COMPONENT_SCHEME, $scheme ) )
			{
				if ( $autoEncode )
				{
					$scheme = 'http';
				}
				else
				{
					throw new UrlException('INVALID_SCHEME');
				}
			}
			
			/* Set it. Though case-insensitve, we should only produce lowercase schemes */
			$return[ static::COMPONENT_SCHEME ] = mb_strtolower( $scheme );
			
		}
		/* If it's protocol relative, take the // off the URL for the rest of of our processing */
		else
		{
			$url = mb_substr( $url, 2 );
		}
		
		/* The authority component is preceded by a double slash ("//") and is  terminated by the next slash ("/"), question mark ("?"), or number
			sign ("#") character, or by the end of the URI */
		preg_match( '/^(.+?)(\/|\?|\#|$)/', $url, $matches );
		$authority = $matches[1];
		$url = mb_substr( $url, mb_strlen( $matches[1] ) );
		
		/* If there's an @ in the authority, everything before it is user info */
		$atSymbolPosition = mb_strpos( $authority, '@' );
		if ( $atSymbolPosition !== FALSE )
		{
			/* Take out the user information... */
			$userInfo = mb_substr( $authority, 0, $atSymbolPosition );
			$authority = mb_substr( $authority, $atSymbolPosition + 1 );
			
			/* If there's a : in the user information, we have a username and password */
			$colonPosition = mb_strpos( $userInfo, ':' );
			if ( $colonPosition !== FALSE )
			{
				$username = mb_substr( $userInfo, 0, $colonPosition );
				$password = mb_substr( $userInfo, $colonPosition + 1 );
			}
			/* Otherwise it's just a username */
			else
			{
				$username = $userInfo;
				$password = NULL;
			}
			
			/* Validate and set the username */
			if ( !static::validateComponent( static::COMPONENT_USERNAME, $username ) )
			{
				if ( $autoEncode )
				{
					$return[ static::COMPONENT_USERNAME ] = $username;
				}
				else
				{
					throw new UrlException('INVALID_USERNAME');
				}
			}
			else
			{
				$return[ static::COMPONENT_USERNAME ] = static::decodeComponent( static::COMPONENT_USERNAME, $username );
			}
			
			/* Validate and set the password if there is one */
			if ( $password !== NULL )
			{
				if ( !static::validateComponent( static::COMPONENT_PASSWORD, $password ) )
				{
					if ( $autoEncode )
					{
						$return[ static::COMPONENT_PASSWORD ] = $password;
					}
					else
					{
						throw new UrlException('INVALID_PASSWORD');
					}
				}
				else
				{
					$return[ static::COMPONENT_PASSWORD ] = static::decodeComponent( static::COMPONENT_PASSWORD, $password );
				}
			}
		}
		
		/* If the authority ends in a : followed by a number, that's the port */
		if ( preg_match( '/:(\d*)$/', $authority, $matches ) )
		{
			$return[ static::COMPONENT_PORT ] = intval( $matches[1] );
			$authority = mb_substr( $authority, 0, -mb_strlen( $matches[0] ) );
		}
		
		/* If the host contains non-ASCII characters, Puncody encode them */
		if ( !preg_match( '/^[\x00-\x7F]*$/', $authority ) )
		{
			if ( static::$punycode === NULL )
			{
				IPS::$PSR0Namespaces['TrueBV'] = \IPS\ROOT_PATH . "/system/3rd_party/php-punycode";
				static::$punycode = new Punycode();
			}

			try
			{
				$authority = static::$punycode->encode( $authority );
			}
			catch( OutOfBoundsException $e )
			{
				/* If we are not auto-encoding, through an exception, otherwise we can just fix it */
				if( !$autoEncode )
				{
					throw new UrlException('INVALID_HOST');
				}
			}
		}
		
		/* Set the host */
		if ( !static::validateComponent( static::COMPONENT_HOST, $authority ) )
		{
			if ( $autoEncode )
			{
				$return[ static::COMPONENT_HOST ] = $authority;
			}
			else
			{
				throw new UrlException('INVALID_HOST');
			}
		}
		else
		{
			$return[ static::COMPONENT_HOST ] = static::decodeComponent( static::COMPONENT_HOST, $authority );
		}
		
		/* There might be nothing left at this point, in which case we're done */
		if ( !$url )
		{
			return $return;
		}
					
		/* The path is terminated by the first question mark ("?") or number sign ("#") character, or by the end of the URI. */
		preg_match( '/^(.*?)(\?|\#|$)/', $url, $matches );
		if ( !static::validateComponent( static::COMPONENT_PATH, $matches[1] ) )
		{
			if ( $autoEncode )
			{
				$return[ static::COMPONENT_PATH ] = $matches[1];
			}
			else
			{
				throw new UrlException('INVALID_PATH');
			}
		}
		else
		{
			$return[ static::COMPONENT_PATH ] = static::decodeComponent( static::COMPONENT_PATH, $matches[1] );
		}
		$url = mb_substr( $url, mb_strlen( $matches[0] ) );
		
		/* There might be nothing left at this point, in which case we're done */
		if ( !$url )
		{
			return $return;
		}
		
		/* If the terminating character was a ? in $matches[2], then we have a query string */
		if ( $matches[2] === '?' )
		{
			/* The query component is terminated by a number sign ("#") character or by the end of the URI. */
			preg_match( '/^(.*?)(\#|$)/', $url, $matches );
			if ( !static::validateComponent( static::COMPONENT_QUERY, $matches[1] ) )
			{
				if ( $autoEncode )
				{
					$return[ static::COMPONENT_QUERY ] = $matches[1];
				}
				else
				{
					throw new UrlException('INVALID_QUERY');
				}
			}
			else
			{
				$return[ static::COMPONENT_QUERY ] = static::convertQueryAsStringToArray($matches[1], TRUE);
			}
			$url = mb_substr( $url, mb_strlen( $matches[1] ) );
		}
		
		/* If there's anything left, it's the fragment */
		if ( $url )
		{
			$url = ltrim( $url, '#' );
			if ( !static::validateComponent( static::COMPONENT_FRAGMENT, $url ) )
			{
				if ( $autoEncode )
				{
					$return[ static::COMPONENT_FRAGMENT ] = $url;
				}
				else
				{
					throw new UrlException('INVALID_FRAGMENT');
				}
			}
			else
			{
				$return[ static::COMPONENT_FRAGMENT ] = static::decodeComponent( static::COMPONENT_FRAGMENT, $url );
			}
		}

		/* Return */
		return $return;
	}
	
	/**
	 * Convert an array of query parameters to a query string
	 *
	 * @param array $query	The query as an array (e.g. [ 'foo'=>'bar', 'moo'=>'baz' ])
	 * @param bool $encode	If true, will encode for output
	 * @return	string
	 */
	public static function convertQueryAsArrayToString( array $query, bool $encode=FALSE ): string
	{
		$return = array();
		foreach ( $query as $k => $v )
		{
			if ( $encode )
			{
				$k = static::encodeComponent( static::COMPONENT_QUERY_KEY, $k );
			}
			
			if ( is_array( $v ) )
			{
				$return[] = static::squashQueryStringArray($k, $v);
			}
			elseif ( $v !== NULL )
			{
				if ( $encode )
				{
					$v = static::encodeComponent( static::COMPONENT_QUERY_VALUE, $v );
				}
				$return[] = "{$k}={$v}";
			}
			else
			{
				$return[] = "{$k}";
			}
		}
		return implode( '&', $return );
	}
	
	/**
	 * Convert an array within an array of query parameters to a query string
	 *
	 * @param string $key	The key for this query string parameter
	 * @param array $value	The query as an array (e.g. [ 'foo'=>'bar', 'moo'=>'baz' ])
	 * @param bool $encode	If true, will encode for output
	 * @return	string
	 */
	protected static function squashQueryStringArray( string $key, array $value, bool $encode=FALSE ): string
	{
		$return = array();
		foreach ( $value as $k => $v )
		{
			if ( $encode )
			{
				$k = static::encodeComponent( static::COMPONENT_QUERY_KEY, $k );
			}
			
			if ( is_array( $v ) )
			{
				$return[] = static::squashQueryStringArray("{$key}[{$k}]", $v, $encode);
			}
			else
			{
				if ( $encode )
				{
					$v = static::encodeComponent( static::COMPONENT_QUERY_VALUE, $v );
				}
				
				$return[] = "{$key}[{$k}]={$v}";
			}
		}
		return count( $return ) ? implode( '&', $return ) : '';
	}
		
	/**
	 * Convert a query string to an array of query parameters
	 *
	 * @param	string|null	$query	The query string as a string (e.g. "foo=bar&moo=baz")
	 * @param bool $decode	If true, will decode
	 * @return	array
	 */
	protected static function convertQueryAsStringToArray( ?string $query, bool $decode=FALSE ): array
	{
		$return = array();
		
		if ( $query !== NULL )
		{
			foreach ( explode( '&', $query ) as $part )
			{
				$keyAndValue = explode( '=', $part, 2 );
				
				if ( $decode )
				{
					$keyAndValue[0] = static::decodeComponent( static::COMPONENT_QUERY_KEY, $keyAndValue[0] );
					if ( isset( $keyAndValue[1] ) )
					{
						$keyAndValue[1] = static::decodeComponent( static::COMPONENT_QUERY_VALUE, $keyAndValue[1] );
					}
				}
				
				if ( isset( $keyAndValue[1] ) )
				{
					if ( preg_match( '/^([^\[]*)(\[.*\])$/', $keyAndValue[0], $matches ) )
					{
						static::_pushQueryStringPartIntoArray( $return, $matches[1], $matches[2], $keyAndValue[1] );
					}
					else
					{
						$return[ $keyAndValue[0] ] = $keyAndValue[1];
					}
				}
				else
				{
					$return[ $keyAndValue[0] ] = NULL;
				}
			}
		}
		
		return $return;
	}
	
	/**
	 * Utility function used by convertQueryAsStringToArray()
	 *
	 * @param array $return			The current $return value being worked on, passed by reference
	 * @param string $mainKey		The main key, for example, if parsing "foo[x][y]=bar", the value will be "foo"
	 * @param string $subKeyNames	The sub keys in square brackets, for example, if parsing "foo[x][y]=bar", the value will be "[x][y]"
	 * @param string $value			The value, for example, if parsing "foo[x][y]=bar", the value will be "bar"
	 * @return	void
	 */
	protected static function _pushQueryStringPartIntoArray( array &$return, string $mainKey, string $subKeyNames, string $value ) : void
	{
		preg_match_all( '/\[([^\]]*)\]/', $subKeyNames, $matches );
		
		if ( !isset( $return[ $mainKey ] ) )
		{
	        $return[ $mainKey ] = array();
	    }
	    
	    if ( is_array( $return[ $mainKey ] ) )
	    {
		    $workingArray =& $return[ $mainKey ];
	    }
	    else
	    {
		    $workingArray[] =& $return[ $mainKey ];
	    }
		
		$last = array_pop( $matches[1] );
		foreach ( $matches[1] as $k )
		{
			if ( $k === '' )
			{
				$workingArray[] = array();
				$workingArray =& $workingArray[ count( $workingArray ) - 1 ];
			}
			elseif ( !isset( $workingArray[ $k ] ) )
			{
				$workingArray[ $k ] = array();
				$workingArray =& $workingArray[ $k ];
			}
		}
		
		if ( $last === '' )
		{
	        $workingArray[] = $value;
	    }
	    else
	    {
	        $workingArray[ $last ] = $value;
	    }
	}

	/**
	 * Get FURL Definition
	 *
	 * @param bool $revert	If TRUE, ignores all customisations and reloads from json
	 * @return	array
	 */
	public static function furlDefinition( bool $revert=FALSE ): array
	{
		return Friendly::furlDefinition( $revert );
	}

	/**
	 * Convert a value into an "SEO Title" for friendly URLs
	 *
	 * @param string $value	Value
	 * @return	string
	 * @note	Many places require an SEO title, so we always need to return something, so when no valid title is available we return a dash
	 */
	public static function seoTitle( string $value ): string
	{
		return Friendly::seoTitle( $value );
	}

	/**
	 * Return a partial query string based on any filters or sort options
	 *
	 * @param array $additionalParams
	 * @return	self
	 */
	public function getSafeUrlFromFilters( array $additionalParams = [] ): self
	{
		$extraUrlParams = [];

		foreach( array_merge( ['filter', 'sortby', 'sortdirection'], $additionalParams ) as $param )
		{
			if ( isset( Request::i()->$param ) )
			{
				$extraUrlParams[ $param ] = Request::i()->$param;
			}
		}

		if ( count( $extraUrlParams ) )
		{
			return $this->setQueryString( $extraUrlParams );
		}
		else
		{
			return $this;
		}
	}
}