Source: urls.js

/*!
 * Module Urls
 */

/**
 * @namespace Urls
 */

const MODULE_NAME = 'Urls';



//###[ IMPORTS ]########################################################################################################

import {
	hasValue,
	orDefault,
	size,
	assert,
	isFunction,
	isString,
	isArray,
	isObject,
	isPlainObject,
	isNaN,
	isEmpty
} from './basic.js';
import {log} from './logging.js';
import {replace} from './strings.js';



//###[ DATA ]###########################################################################################################

export const COMMON_TOP_LEVEL_DOMAINS = [
	'aero', 'biz', 'cat', 'com', 'coop', 'edu', 'gov', 'info', 'int', 'jobs', 'mil', 'mobi', 'museum', 'name', 'net',
	'org', 'travel', 'ac', 'ad', 'ae', 'af', 'ag', 'ai', 'al', 'am', 'an', 'ao', 'aq', 'ar', 'as', 'at', 'au', 'aw',
	'az', 'ba', 'bb', 'bd', 'be', 'bf', 'bg', 'bh', 'bi', 'bj', 'bm', 'bn', 'bo', 'br', 'bs', 'bt', 'bv', 'bw', 'by',
	'bz', 'ca', 'cc', 'cd', 'cf', 'cg', 'ch', 'ci', 'ck', 'cl', 'cm', 'cn', 'co', 'cr', 'cs', 'cu', 'cv', 'cx', 'cy',
	'cz', 'de', 'dj', 'dk', 'dm', 'do', 'dz', 'ec', 'ee', 'eg', 'eh', 'er', 'es', 'et', 'eu', 'fi', 'fj', 'fk', 'fm',
	'fo', 'fr', 'ga', 'gb', 'gd', 'ge', 'gf', 'gg', 'gh', 'gi', 'gl', 'gm', 'gn', 'gp', 'gq', 'gr', 'gs', 'gt', 'gu',
	'gw', 'gy', 'hk', 'hm', 'hn', 'hr', 'ht', 'hu', 'id', 'ie', 'il', 'im', 'in', 'io', 'iq', 'ir', 'is', 'it', 'je',
	'jm', 'jo', 'jp', 'ke', 'kg', 'kh', 'ki', 'km', 'kn', 'kp', 'kr', 'kw', 'ky', 'kz', 'la', 'lb', 'lc', 'li', 'lk',
	'lr', 'ls', 'lt', 'lu', 'lv', 'ly', 'ma', 'mc', 'md', 'mg', 'mh', 'mk', 'ml', 'mm', 'mn', 'mo', 'mp', 'mq', 'mr',
	'ms', 'mt', 'mu', 'mv', 'mw', 'mx', 'my', 'mz', 'na', 'nc', 'ne', 'nf', 'ng', 'ni', 'nl', 'no', 'np', 'nr', 'nu',
	'nz', 'om', 'pa', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 'pm', 'pn', 'pr', 'ps', 'pt', 'pw', 'py', 'qa', 're', 'ro',
	'ru', 'rw', 'sa', 'sb', 'sc', 'sd', 'se', 'sg', 'sh', 'si', 'sj', 'sk', 'sl', 'sm', 'sn', 'so', 'sr', 'st', 'su',
	'sv', 'sy', 'sz', 'tc', 'td', 'tf', 'tg', 'th', 'tj', 'tk', 'tm', 'tn', 'to', 'tp', 'tr', 'tt', 'tv', 'tw', 'tz',
	'ua', 'ug', 'uk', 'um', 'us', 'uy', 'uz', 'va', 'vc', 've', 'vg', 'vi', 'vn', 'vu', 'wf', 'ws', 'ye', 'yt', 'yu',
	'za', 'zm', 'zr', 'zw', 'local'
];

const
	URISON_VALUE_FORMAT = `[^\-0123456789 '!:(),*@$][^ '!:(),*@$]*`,
	URISON_VALUE_REX = new RegExp(`^${URISON_VALUE_FORMAT}$`),
	URISON_NEXT_VALUE_REX = new RegExp(URISON_VALUE_FORMAT, 'g')
;



//###[ HELPERS ]########################################################################################################

/**
 * A parser to translate a Rison string such as `'(key1:value,key2:!t,key3:!(!f,42,!n))'` into its
 * JSON object/array representation `{key1 : 'value', key2 : true, key3 : [false, 42, null]}`. This is a helper class
 * for the public Urison class below.
 *
 * @protected
 * @memberof Urls
 * @name UrisonParser
 *
 * @see https://github.com/Nanonid/rison
 * @example
 * new UrisonParser(error => { console.error(error); });
 */
class UrisonParser {

	#__className__ = 'UrisonParser';
	#errorHandler;
	#string = '';
	#index = 0;
	#message = null;
	#bangTokens;
	#tokenMap;

	/**
	 * Creates a new UrisonParser instance.
	 *
	 * All errors in this class result in a console error message rather than an exception. To work with occurring
	 * errors, define an errorCallback for the constructor and throw errors from there if needed.
	 *
	 * @param {Function} [errorHandler=null] - function to call in case parsing fails, receives the error message and the character index as parameters
	 */
	constructor(errorHandler=null){
		const instance = this;

		this.#errorHandler = isFunction(errorHandler) ? errorHandler : null;

		// syntax tokens preceded with a "!" and the values they represent in JSON
		this.#bangTokens = {
			't' : true,
			'f' : false,
			'n' : null,
			'(' : this.#parseArray
		};

		// syntax structure tokens and the procedures, that transform these tokens into json structure
		this.#tokenMap = {
			'!' : function(){
				const char = instance.#string.charAt(instance.#index++);
				if( char === '' ) return instance.#error('"!" at end of input');

				const value = instance.#bangTokens[char];
				if( value === undefined ) return instance.#error(`unknown literal: "!${char}"`);
				if( isFunction(value) ) return value.call(this);

				return value;
			},

			'(' : function(){
				const res = {};
				let
					first = true,
					char
				;

				while( (char = instance.#next()) !== ')' ){
					if( !first ){
						if( char !== ',' ){
							return instance.#error('missing ","');
						}
					} else if( char === ',' ){
						return instance.#error('extra ","');
					} else {
						instance.#index--;
					}

					const key = instance.#readValue();
					if( key === undefined ) return undefined;
					if( instance.#next() !== ':' ) return instance.#error('missing ":"');

					const value = instance.#readValue();
					if( value === undefined ) return undefined;
					res[key] = value;

					first = false;
				}

				return res;
			},

			"'" : function(){
				const segments = [];
				let
					i = instance.#index,
					start = instance.#index,
					char
				;

				while( (char = instance.#string.charAt(i++)) !== "'" ){
					if( char === '' ) return instance.#error(`unmatched "'"`);
					if( char === '!' ){
						if( start < (i - 1) ){
							segments.push(instance.#string.slice(start, i - 1));
						}
						char = instance.#string.charAt(i++);
						if( ['!', "'"].includes(char) ){
							segments.push(char);
						} else {
							return instance.#error(`invalid string escape: "!${char}"`);
						}
						start = i;
					}
				}
				if( start < (i - 1) ){
					segments.push(instance.#string.slice(start, i - 1));
				}
				instance.#index = i;

				return (segments.length === 1) ? segments[0] : segments.join('');
			},

			'-' : function(){
				const
					start = instance.#index - 1,
					numberTypeMap = {
						'int+.' : 'frac',
						'int+e' : 'exp',
						'frac+e' : 'exp'
					}
				;
				let
					s = instance.#string,
					i = instance.#index,
					numberType = 'int',
					permittedSigns = '-'
				;

				do {
					const char = s.charAt(i++);
					if( char === '' ) break;
					if( (char >= '0') && (char <= '9') ) continue;
					if( permittedSigns.includes(char) ){
						permittedSigns = '';
						continue;
					}

					numberType = numberTypeMap[`${numberType}+${char.toLowerCase()}`];
					if( numberType === 'exp' ){
						permittedSigns = '-';
					}
				} while( numberType !== undefined );

				i--;
				instance.#index = i;
				s = s.slice(start, i);
				if( s === '-' ) return instance.#error('invalid number');
				return Number(s);
			}
		};

		(function(tokenMap){
			for( let i = 0; i <= 9; i++ ){
				tokenMap[`${i}`] = tokenMap['-'];
			}
		})(this.#tokenMap);
	}



	/**
	 * Parses a Rison string into a JSON object.
	 * Resets internal parsing info, like parsing index, to start new parsing process.
	 *
	 * @param {String} risonString - the string to parse
	 * @returns {Object|Array|undefined} the parsed JSON object or undefined, in case parsing failed
	 *
	 * @example
	 * (new UrisonParser()).parse('(key1:value,key2:!t,key3:!(!f,42,!n))')
	 * => {key1 : 'value', key2 : true, key3 : [false, 42, null]}
	 */
	parse(risonString){
		this.#string = `${risonString}`;
		this.#index = 0;
		this.#message = null;

		let value = this.#readValue();

		const trailingChar = this.#next();
		if( !this.#message && (trailingChar !== undefined) ){
			let detailMessage;
			if( /\s/.test(trailingChar) ){
				detailMessage = 'whitespace detected';
			} else {
				detailMessage = `trailing char "${trailingChar}"`;
			}
			value = this.#error(`unable to parse string "${risonString}", ${detailMessage}`);
		}

		if( this.#message && this.#errorHandler ){
			this.#errorHandler(this.#message, this.#index);
		}

		return value;
	}



	/**
	 * Parses the structure of an array. Is a helper function for #parse/#readValue.
	 * Works with previously set internal parsing info such as string and parsing index.
	 *
	 * @returns {Array|undefined} the parsed array or undefined, in case parsing failed
	 *
	 * @private
	 * @example
	 * this.#parseArray()
	 * => [true, null, 'value']
	 */
	#parseArray(){
		const res = [];
		let char;

		while( (char = this.#next()) !== ')' ){
			if( char === '' ) return this.#error('unmatched "!("');

			if( !isEmpty(res) ){
				if( char !== ',' ){
					return this.#error('missing ","');
				}
			} else if( char === ',' ){
				return this.#error('extra ","');
			} else {
				this.#index--;
			}

			const value = this.#readValue();
			if( value === undefined ) return undefined;
			res.push(value);
		}

		return res;
	}



	/**
	 * Either reads the next value or key in the current parser string or triggers recursive handling of syntax tokens.
	 * Progresses parsing to the next section so to speak.
	 *
	 * @returns {Object|Array|String|Number|Boolean|null|undefined} the parsed value or undefined if parsing failed
	 *
	 * @private
	 * @example
	 * this.#readValue()
	 * => 'valueorkeyorstructure'
	 */
	#readValue(){
		const
			char = this.#next(),
			mapper = this.#tokenMap[char]
		;

		if( isFunction(mapper) ) return mapper.apply(this);

		const i = this.#index - 1;
		URISON_NEXT_VALUE_REX.lastIndex = i;
		const matches = URISON_NEXT_VALUE_REX.exec(this.#string);
		if( !isEmpty(matches) ){
			const id = matches[0];
			this.#index = i + id.length;
			return id;
		}

		if( hasValue(char) && (char !== '') ) return this.#error(`invalid character "${char}"`);
		return this.#error('empty expression');
	}



	/**
	 * Reads the next character of the currently given Rison string, increments the index
	 * and returns the character.
	 *
	 * @returns {String|undefined} the next character or undefined if there is none
	 *
	 * @private
	 * @example
	 * this.#next()
	 * => '!'
	 */
	#next(){
		let
			i = this.#index,
			char
		;

		if( i >= this.#string.length ) return undefined;
		char = this.#string.charAt(i++);
		this.#index = i;

		return char;
	}



	/**
	 * Sets the error message and writes it to `console.error()` for info purposes.
	 * This method does _not_ throw an exception, for this, please set an error handler
	 * in the constructor and throw it externally.
	 *
	 * @param {String} message - the error message
	 * @returns {undefined} is always undefined to be uniform return value for failed value parsing in case of error
	 *
	 * @private
	 * @example
	 * this.#error('oh noez')
	 * => undefined
	 */
	#error(message){
		console.error(`${this.#__className__} error: `, message);
		this.#message = message;
		return undefined;
	}

}



//###[ EXPORTS ]########################################################################################################

/**
 * @namespace Urls:urlHref
 */

/**
 * Will return a fully qualified URL based on the given URL base string for use as a href/source-value
 * or navigation target.
 *
 * Provide a base URL or leave the URL out, to use the current URL.
 * Add GET-parameters (adding to those already present in the URL), define an anchor (or automatically get the one
 * defined in the URL).
 *
 * Provided URLs are handled with some automagic:
 * - a URL starting with "//" will receive the current page protocol
 * - a URL starting with a single "/" will be seen as relative and will be expanded to an absolute URL, based
 *   on the current URL
 * - a URL starting with "?" will be treated as a singular query string, resulting in the query being added to the
 *   current URL, replacing any present query
 * - a URL starting with "#" will be treated as a singular hash string, resulting in the hash being added to the
 *   current URL, replacing any present hash
 * - if, after all automagic applied, the URL still does not start with a http-protocol, the current page's protocol
 *   will be added
 *
 * Provided params have to be a flat plain object, with ordinal values or arrays of ordinal values on the first level.
 * Everything else will be stringified and url-encoded as is. Usually, parameters defined here add to present
 * parameters in the URL. To force-override present values, declare the param name with a "!" prefix
 * (`{'!presentparam' : 'new'}`).
 *
 * This method implements some quality-of-life improvements, that differ from the native result of `new URL().href`:
 * - `+`-encoding for whitespace is replaced with `%20`, while `+` will stay what it is, a verbatim URL-safe character
 *   with repeating keys (`tags=1&tags=2&tags=3`)
 * - empty parameters are rendered without "=". So, "?test=&foo" will be "?test&foo"
 * - `path/?` will become just `path?`
 * - `path/#` will become just `path#`
 * - trailing slashes will be removed
 * - parameters will be sorted alphabetically by keys
 *   (value order will be kept if possible, might change, when using markListParams)
 * - identical key/value pairs will be reduced to one occurrence, so `?q=a&q=a` will become `?q=a`
 *
 * @param {?String|URL} [url=null] - the base URL to use, if nullish current location is used
 * @param {?Object} [params=null] - plain object of GET-parameters to add to the url
 * @param {?String} [anchor=null] - anchor/hash to set, has precedence over URL hash
 * @param {?Boolean} [markListParams=false] - if true, params with more than one value will be marked with "[]" preceding the param name
 * @param {?Boolean} [keepEncodedUrlSafeChars=false] - if true, encoded chars, which are URL-safe, are kept encoded, instead of being returned raw
 * @throws error if url is not usable
 * @returns {String} the created URL including parameters and anchor
 *
 * @memberof Urls:urlHref
 * @alias urlHref
 * @example
 * buildUrl('https://test.com', {search : 'kittens', order : 'asc'}, 'fluffykittens');
 * => 'https://test.com?search=kittens&order=asc#fluffykittens'
 * buildUrl(null, {order : 'desc'});
 * => 'https://current.url?order=desc'
 */
export function urlHref(url=null, params=null, anchor=null, markListParams=false, keepEncodedUrlSafeChars=false){
	const __methodName__ = 'urlHref';

	url = orDefault(url, window.location.href, 'str');
	params = isPlainObject(params) ? params : null;
	anchor = orDefault(anchor, null, 'str');
	markListParams = orDefault(markListParams, false, 'bool');

	if( url === 'about:blank' ) return url;
	if( url.trim() === '' ){
		url = window.location.href;
	}
	if( url.startsWith('//') ){
		url = `${window.location.protocol}${url}`;
	} else if( url.startsWith('/') ){
		url = `${window.location.origin}${url}`;
	} else if( url.startsWith('?') ){
		const anchorPart = !url.includes('#') ? window.location.href.split('#')[1] : null;
		url = `${window.location.href.split('?')[0]}${url}${hasValue(anchorPart) ? '#'+anchorPart : ''}`;
	} else if( url.startsWith('#') ){
		url = `${window.location.href.split('#')[0]}${url}`;
	}
	if( !(/^https?:\/\//.test(url)) ){
		url = `${window.location.protocol}//${url}`;
	}

	let urlObj;
	try {
		urlObj = new URL(url);
	} catch(ex){
		throw new Error(`${MODULE_NAME}:${__methodName__} | unusable URL "${url}" [${ex}]`);
	}

	if( hasValue(anchor) ){
		urlObj.hash = anchor.startsWith('#') ? anchor : `#${anchor}`;
	}

	const urlParams = urlObj.searchParams;

	if( hasValue(params) ){
		for( let paramName in params ){
			let overrideName = paramName;
			if( paramName.startsWith('!') ){
				overrideName = paramName.slice(1);
			}

			if( overrideName !== paramName ){
				urlParams.delete(overrideName);
			}

			[].concat(params[paramName]).forEach(paramValue => {
				urlParams.append(overrideName, `${paramValue}`);
			});
		}
	}

	if( markListParams ){
		for( let k of urlParams.keys() ){
			const cleanKey = k.replace(/\[]$/, '');

			let presentValues = [].concat(urlParams.getAll(k));
			if( k.endsWith('[]') ){
				presentValues = presentValues.concat(urlParams.getAll(cleanKey));
			}

			if( (presentValues.length > 1) ){
				urlParams.delete(k);
				urlParams.delete(cleanKey);
				presentValues.forEach(v => {
					urlParams.append(`${cleanKey}[]`, v);
				});
			}
		}
	}

	let	query = urlObj.search
		.replace(/\+/g, '%20')
		.replace(/=&/g, '&')
		.replace(/=$/g, '')
	;

	if( !keepEncodedUrlSafeChars ){
		query = query
			.replaceAll('%2B', '+')
			.replaceAll('%5B', '[')
			.replaceAll('%5D', ']')
		;
	}

	let queryParts = query.startsWith('?') ? query.slice(1).split('&') : []
	if( !isEmpty(queryParts) ){
		queryParts.sort((a, b) => {
			const
				aKey = a.split('=')[0],
				bKey = b.split('=')[0]
			;
			return (aKey < bKey) ? -1 : ((aKey > bKey) ? 1 : 0 );
		});
		queryParts = queryParts.filter((part, index) => {
			if( index >= 1 ){
				return queryParts.indexOf(part) === index;
			} else {
				return true;
			}
		});
		query = `?${queryParts.join('&')}`;
	}

	let finalUrl;
	if( !isEmpty(query) ){
		finalUrl = `${urlObj.href.split('?')[0]}${query}${urlObj.hash}`.replace('/?', '?');
	} else if( !isEmpty(urlObj.hash) && isEmpty(query) ){
		finalUrl = `${urlObj.href.split('#')[0]}${urlObj.hash}`.replace('/#', '#');
	} else {
		finalUrl = urlObj.href.replace(/\/$/, '');
	}

	return keepEncodedUrlSafeChars ? finalUrl : replace(
		finalUrl,
		['%2C', '%3A', '%40', '%24', '%2F', '%2B'],
		[',', ':', '@', '$', '/', '+']
	);
}



/**
 * @namespace Urls:urlParameter
 */

/**
 * Searches for and returns parameters embedded in the provided url containing a query string
 * (make sure all values are url encoded).
 *
 * You may also just provide the query string.
 *
 * Returns a single parameter's value if a parameter name is given, otherwise returns dictionary with all parameters
 * as keys and the associated parameter value.
 *
 * If a parameter has more than one value the values are returned as an array, whether being requested by name
 * or in the dictionary containing all params.
 *
 * If a parameter is set, but has no defined value (name present, but no = before next param)
 * the value is returned as boolean true.
 *
 * @param {?String|URL} [url=null] - the url containing the parameter string, will use current URL if nullish
 * @param {?String} [parameter=null] - the name of the parameter to extract
 * @throws error if given url is not usable
 * @returns {null|true|String|Array|Object} null in case the parameter doesn't exist, true in case it exists but has no value, a string in case the parameter has one value, or an array of values, or a dictionary object of all available parameters with corresponding values
 *
 * @memberof Urls:urlParameter
 * @alias urlParameter
 * @see urlHref
 * @example
 * const hasKittens = urlParameter('//foobar.com/bar?has_kittens', 'has_kittens');
 * => true
 * const hasDoggies = urlParameter('has_doggies=yes&has_doggies', 'has_doggies');
 * => ['yes', true]
 * const allTheData = urlParameter('?foo=foo&bar=bar&bar=barbar&bar');
 * => {foo : 'foo', bar : ['bar', 'barbar', true]}
 */
export function urlParameter(url=null, parameter=null){
	url = urlHref(url, null, null, false, true);
	parameter = orDefault(parameter, null, 'str');

	const
		searchParams = new URL(url).searchParams,
		fMapParameterValue = parameterValue => ((parameterValue === '') ? true : parameterValue)
	;

	if( hasValue(parameter) ){
		const parameterValues = searchParams.getAll(parameter);
		if( parameterValues.length === 0 ){
			return null;
		} else if( parameterValues.length === 1 ){
			return fMapParameterValue(parameterValues[0]);
		} else {
			return Array.from(new Set(parameterValues.map(fMapParameterValue)));
		}
	} else {
		const parameters = {};
		Array.from(searchParams.keys()).forEach(parameterName => {
			const parameterValues = searchParams.getAll(parameterName);
			if( parameterValues.length > 0 ){
				parameters[parameterName] =
					(parameterValues.length === 1)
					? fMapParameterValue(parameterValues[0])
					: Array.from(new Set(parameterValues.map(fMapParameterValue)))
				;
			}
		});
		return (size(parameters) > 0) ? parameters : null;
	}
}



/**
 * @namespace Urls:urlParameters
 */

/**
 * Searches for and returns parameters embedded in provided url with a parameter string.
 *
 * Semantic shortcut version of urlParameter without any given parameter.
 *
 * @param {?String|URL} [url=null] - the url containing the parameter string, will use current URL if nullish
 * @throws error if given url is not usable
 * @returns {Object|null} dictionary object of all parameters or null if url has no parameters
 *
 * @memberof Urls:urlParameters
 * @alias urlParameters
 * @see urlParameter
 * @example
 * const allParams = urlParameters('http://www.foobar.com?foo=foo&bar=bar&bar=barbar&bar');
 * => {foo : 'foo', bar : ['bar', 'barbar', true]}
 */
export function urlParameters(url=null){
	return urlParameter(url);
}



/**
 * @namespace Urls:urlAnchor
 */

/**
 * Returns the currently set URL-Anchor on given URL.
 *
 * Theoretically, this function also works with any other string containing a hash (as long as there is "#" included),
 * since this implementation does not lean on "new URL()", but is a simple string operation.
 *
 * In comparison to "location.hash", this function actually decodes the hash automatically.
 *
 * @param {?String|URL} [url=null] - the url, in which to search for a hash, uses current url if nullish
 * @param {?Boolean} [withCaret=false] - defines if the returned anchor value should contain leading "#"
 * @throws error if given url is not usable
 * @returns {String|null} current anchor value or null if no anchor was found
 *
 * @memberof Urls:urlAnchor
 * @alias urlAnchor
 * @example
 * const anchorWithoutCaret = urlAnchor('https://foobar.com#test');
 * => 'test'
 * const hrefAnchorWithCaret = urlAnchor(linkElement.getAttribute('href'), true);
 * => '#test'
 * const decodedAnchorFromLocation = urlAnchor(window.location.hash);
 */
export function urlAnchor(url=null, withCaret=false){
	url = urlHref(url);
	withCaret = orDefault(withCaret, false, 'bool');

	const urlParts = url.split('#');

	let anchor = (urlParts.length > 1) ? decodeURIComponent(urlParts[1].trim()) : null;
	if( anchor === '' ){
		anchor = null;
	}
	if( withCaret && hasValue(anchor) ){
		anchor = `#${anchor}`;
	}

	return anchor;
}



/**
 * @namespace Urls:addNextParameter
 */

/**
 * Adds a "next"-parameter to a given URL. If there is already a parameter of that name, it will be replaced.
 *
 * A "next"-parameter is usually used to relay a second URL, which should be redirected to after something happens,
 * such as a login or another (possibly automatic) action.
 *
 * @param {?String} [url=''] - the URL to add the next parameter to, if left empty, will be "", which is synonymous with the current URL
 * @param {?String} [next=''] - the next URL to add as parameter to the given URL (will automatically be URL-encoded)
 * @param {?String} [paramName='next'] - the name of the next parameter
 * @param {?Boolean} [assertSameBaseDomain=false] - if true, url and next must have the same base domain (ignoring subdomains), to prevent injections
 * @param {?Array<String>} [additionalTopLevelDomains=null] - this function uses a list of common TLDs (if assertSameBaseDomain is true), if yours is missing, you may provide it, using this parameter
 * @throws error if url or next are not usable URLs
 * @throws error if assertBaseDomain is true an the base domains of url and next differ
 * @returns {String} the transformed URL with the added next parameter
 *
 * @memberof Urls:addNextParameter
 * @alias addNextParameter
 * @see urlHref
 * @example
 * addNextParameter('https://foobar.com', 'https://foo.bar', 'redirect');
 * => 'https://foobar.com?redirect=https%3A%2F%2Ffoo.bar'
 * addNextParameter('https://foobar.com?next=https%3A%2F%2Ffoo.bar', 'https://kittens.com');
 * => 'https://foobar.com?next=https%3A%2F%2Fkittens.com'
 */
export function addNextParameter(url, next, paramName='next', assertSameBaseDomain=false, additionalTopLevelDomains=null){
	const __methodName__ = 'addNextParameter';

	url = urlHref(url);
	next = urlHref(next);
	paramName = orDefault(paramName, 'next', 'str');
	assertSameBaseDomain = orDefault(assertSameBaseDomain, true, 'bool');

	if( assertSameBaseDomain ){
		assert(
			evaluateBaseDomain(new URL(url).hostname, additionalTopLevelDomains) === evaluateBaseDomain(new URL(next).hostname, additionalTopLevelDomains),
			`${MODULE_NAME}:${__methodName__} | different base domains in url and next`
		);
	}

	const params = new URL(url).searchParams;
	if( params.has(paramName) ){
		log().info(`${MODULE_NAME}:${__methodName__} | replaced "${paramName}" value "${params.get(paramName)}" with "${next}"`);
	}

	return urlHref(url, {[`!${paramName}`] : next});
}



/**
 * @namespace Urls:addCacheBuster
 */

/**
 * Adds a cache busting parameter to a given URL. If there is already a parameter of that name, it will be replaced.
 * This prevents legacy browsers from caching requests by changing the request URL dynamically, based on current time.
 *
 * @param {?String|URL} [url=null] - the URL to add the cache busting parameter to, if nullish, the current URL will be used
 * @param {?String} [paramName='_'] - the name of the cache busting parameter
 * @throws error if url is not a usable URL
 * @returns {String} the transformed URL with the added cache busting parameter
 *
 * @memberof Urls:addCacheBuster
 * @alias addCacheBuster
 * @see urlHref
 * @example
 * addCacheBuster('https://foobar.com');
 * => 'https://foobar.com?_=1648121948009'
 * addCacheBuster('https://foobar.com?next=https%3A%2F%2Ffoo.bar', 'nocache');
 * => 'https://foobar.com?next=https%3A%2F%2Ffoo.bar&nocache=1648121948009'
 */
export function addCacheBuster(url=null, paramName='_'){
	const __methodName__ = 'addCacheBuster';

	url = urlHref(url);

	const
		params = new URL(url).searchParams,
		buster = Date.now()
	;

	if( params.has(paramName) ){
		log().info(`${MODULE_NAME}:${__methodName__} | replaced "${paramName}" value "${params.get(paramName)}" with "${buster}"`);
	}

	return urlHref(url, {[`!${paramName}`] : buster})
}



/**
 * @namespace Urls:evaluateBaseDomain
 */

/**
 * Walks a domain string (e.g. foobar.barfoo.co.uk) backwards, separated by dots, skips over all top level
 * domains it finds and includes the first non-TLD value to retrieve the base domain without any subdomains
 * (e.g. barfoo.co.uk).
 *
 * This is not completely fool-proof in case of very exotic TLDs, but quite robust in most cases.
 *
 * This method is particularly helpful if you want to set a domain cookie while being on a subdomain.
 *
 * @param {String} domain - the domain string (hostname), which should be evaluated; you may also provide a full, parsable URL, from which to extract the hostname
 * @param {?Array<String>} [additionalTopLevelDomains=null] - this function uses a list of common TLDs, if yours is missing, you may provide it, using this parameter
 * @returns {String} the evaluated base domain string
 *
 * @memberof Urls:evaluateBaseDomain
 * @alias evaluateBaseDomain
 * @example
 * evaluateBaseDomain('foobar.barfoo.co.uk');
 * => 'barfoo.co.uk'
 * evaluateBaseDomain('https://foobar.barfoo.co.uk/?foo=bar');
 * => 'barfoo.co.uk'
 */
export function evaluateBaseDomain(domain, additionalTopLevelDomains=null){
	domain = orDefault(domain, window.location.hostname, 'str');
	additionalTopLevelDomains = orDefault(additionalTopLevelDomains, null, 'arr');

	let url;
	try {
		url = new URL(domain);
	} catch(error){
		url = null;
	}
	if( hasValue(url) ){
		domain = url.hostname;
	}

	const
		topLevelDomains = new Set([
			...COMMON_TOP_LEVEL_DOMAINS,
			...(hasValue(additionalTopLevelDomains) ? additionalTopLevelDomains.map(tld => `${tld}`) : [])
		]),
		domainParts = domain.split('.').reverse()
	;

	let baseDomain = domain;

	if( domainParts.length > 2 ){
		let i;

		for( i = 0; i < domainParts.length; i++ ){
			if( !topLevelDomains.has(domainParts[i]) ){
				break;
			}
		}

		baseDomain = domainParts.slice(0, i + 1).reverse().join('.');
	}

	return baseDomain;
}



/**
 * @namespace Urls:Urison
 */

/**
 * A class, which (re)implements the "Rison" standard of en- and decoding JSON structures to and from URL-safe strings,
 * which can be used as parameter or hash values, while staying readable and avoiding characters, which are not meant
 * to be used inside a URL.
 *
 * This is a renamed reimplementation of ES5 Rison, which has not gotten an update for years and should be fully
 * compatible with other available parsers for that standard.
 *
 * The basic idea is this:
 * We have some kind of complex data structure we want to serialize to a URL, to represent a current search and filter
 * setup for example. This structure should also be retrievable easily after a reload, to be able to use that config
 * as a starting point again for the page's search and filter widgets. A big plus here would be readability, which,
 * for instance, gets lost, if we just were to url-encode JSON as-is.
 *
 * This class provides the means to en- and decode JSON structures for usage in URLs. Additionally, it provides methods
 * to explicitly work with objects and array, for the en- and decoding process, removing the necessity to include
 * brackets into the result, making the string even leaner.
 *
 * See class documentation below for details.
 *
 * @memberof Urls:Urison
 * @name Urison
 *
 * @see Urison
 * @see https://github.com/Nanonid/rison
 * @example
 * (new Urison()).encode({key1 : 'value', key2 : true, key3 : [false, 42, null]})
 * => '(key1:value,key2:!t,key3:!(!f,42,!n))'
 * (new Urison()).decode('(key1:value,key2:!t,key3:!(!f,42,!n))')
 * => {key1 : 'value', key2 : true, key3 : [false, 42, null]}
 * (new Urison()).encodeObject({key1 : 'value', key2 : true, key3 : [false, 42, null]})
 * => 'key1:value,key2:!t,key3:!(!f,42,!n)'
 * (new Urison()).decodeObject('key1:value,key2:!t,key3:!(!f,42,!n)')
 * => {key1 : 'value', key2 : true, key3 : [false, 42, null]}
 * (new Urison()).encodeArray([false, 42, null])
 * => '!f,42,!n'
 * (new Urison()).decodeArray('!f,42,!n')
 * => [false, 42, null]
 */
class Urison {

	#__className__ = 'Urison';
	#autoEscape;
	#autoUnescape;
	#encoders;
	#parser;

	/**
	 * Creates a new Urison en- and decoder.
	 *
	 * @param {Boolean} [autoEscape=true] - if true, all keys and values are automatically uri-encoded and decoded if necessary, set this to false to keep values as is
	 */
	constructor(autoEscape=true){
		const instance = this;

		autoEscape = orDefault(autoEscape, true, 'bool');
		this.#autoEscape = autoEscape ? this.escape : val => val;
		this.#autoUnescape = autoEscape ? decodeURIComponent : val => val;

		// procedure map, defining how data types are string-represented in Rison
		this.#encoders = {
			array(value){
				const res = [];

				for( let v of value ){
					const encodedValue = instance.encode(v);
					if( isString(encodedValue) ){
						res.push(encodedValue);
					}
				}

				return `!(${res.join(',')})`;
			},

			boolean(value){
				return !!value ? '!t' : '!f';
			},

			null(){
				return '!n';
			},

			number(value){
				if( !isFinite(value) ) return '!n';
				return `${value}`.replace(/\+/, '');
			},

			object(value){
				if( hasValue(value) ){
					if( isArray(value) ){
						return this.array(value);
					}

					const keys = Object.keys(value);
					keys.sort();

					const res = [];
					for( let key of keys ){
						const v = instance.encode(value[key]);
						if( isString(v) ){
							const k = isNaN(parseInt(key, 10)) ? this.string(key) : this.number(key);
							res.push(`${k}:${v}`);
						}
					}

					return `(${res.join(',')})`;
				}

				return '!n';
			},

			string(value){
				if( value === '' ) return "''";
				if( URISON_VALUE_REX.test(value) ) return value;

				value = value.replace(/(['!])/g, function(_, quotedChar){
					return `!${quotedChar}`;
				});

				return `'${value}'`;
			}
		};

		this.#parser = (new UrisonParser((error, index) => {
			throw Error(`decoding error [${error}] at string index ${index}`);
		}));
	}



	/**
	 * Encodes a JSON value to a Rison string.
	 *
	 * @param {Array|Object|String|Number|Boolean|null} value - the value to encode
	 * @throws error if encoding fails or value is not usable JSON
	 * @returns {String|undefined} the encoded Rison string or undefined if value cannot be encoded
	 *
	 * @example
	 * (new Urison()).encode({key1 : 'value', key2 : true, key3 : [false, 42, null]})
	 * => '(key1:value,key2:!t,key3:!(!f,42,!n))'
	 */
	encode(value){
		const __methodName__ = 'encode';

		if( isFunction(value?.toJson) ){
			value = value.toJson();
		}

		if( isFunction(value?.toJSON) ){
			value = value.toJSON();
		}

		const encoder = this.#encoders[typeof value];
		if( !isFunction(encoder) ){
			throw new Error(`${this.#__className__}.${__methodName__} | invalid data type`);
		}

		let res;
		try {
			res = encoder.call(this.#encoders, value);
		} catch(ex){
			throw new Error(`${this.#__className__}.${__methodName__} | encoding error [${ex}]`);
		}

		return this.#autoEscape(this.#autoUnescape(res));
	}



	/**
	 * Encodes a JSON value to a Rison string.
	 *
	 * @param {Object} value - the object to encode
	 * @returns {String|undefined} the encoded Rison string or undefined if value cannot be encoded
	 * @throws error if value is not an object
	 *
	 * @example
	 * (new Urison()).encodeObject({key1 : 'value', key2 : true, key3 : [false, 42, null]})
	 * => 'key1:value,key2:!t,key3:!(!f,42,!n)'
	 */
	encodeObject(value){
		const __methodName__ = 'encodeObject';

		if( !isObject(value) ){
			throw new Error(`${this.#__className__}.${__methodName__} | value is not an object`);
		}

		const res = this.#encoders.object(value);
		return this.#autoEscape(this.#autoUnescape(res.substring(1, res.length - 1)));
	}



	/**
	 * Encodes a JSON array to a Rison string.
	 *
	 * @param {Array} value - the array to encode
	 * @returns {String|undefined} the encoded Rison string or undefined if value cannot be encoded
	 * @throws error if value is not an array
	 *
	 * @example
	 * (new Urison()).encodeArray([false, 42, null])
	 * => '!f,42,!n'
	 */
	encodeArray(value){
		const __methodName__ = 'encodeArray';

		if( !isArray(value) ){
			throw new Error(`${this.#__className__}.${__methodName__} | value is not an array`);
		}

		const res = this.#encoders.array(value);
		return this.#autoEscape(this.#autoUnescape(res.substring(2, res.length - 1)));
	}



	/**
	 * Decodes a Rison string to a JSON value.
	 *
	 * @param {String} risonString - the Rison string to decode
	 * @returns {Object|Array|String|Number|Boolean|null} the decoded JSON value
	 * @throws error if decoding fails
	 *
	 * @example
	 * (new Urison()).decode('(key1:value,key2:!t,key3:!(!f,42,!n))')
	 * => {key1 : 'value', key2 : true, key3 : [false, 42, null]}
	 */
	decode(risonString){
		return this.#parser.parse(this.#autoUnescape(risonString));
	}



	/**
	 * Decodes a shortened Rison object string to a JSON object.
	 *
	 * @param {String} risonString - the Rison object string to decode
	 * @returns {Object} the decoded JSON object
	 * @throws error if decoding fails
	 *
	 * @example
	 * (new Urison()).decodeObject('key1:value,key2:!t,key3:!(!f,42,!n)')
	 * => {key1 : 'value', key2 : true, key3 : [false, 42, null]}
	 */
	decodeObject(risonString){
		return this.decode(`(${risonString})`);
	}



	/**
	 * Decodes a shortened Rison array string to a JSON array.
	 *
	 * @param {String} risonString - the Rison array string to decode
	 * @returns {Array} the decoded JSON array
	 * @throws error if decoding fails
	 *
	 * @example
	 * (new Urison()).decodeArray('!f,42,!n')
	 * => [false, 42, null]
	 */
	decodeArray(risonString){
		return this.decode(`!(${risonString})`);
	}



	/**
	 * URI-Escapes a value, if necessary, according to the rules of Rison, which is a little bit
	 * more lax than native uri encoding (allows [,:@$/+]).
	 *
	 * This method has one difference to the reference implementation:
	 * We do _not_ encode whitespace as "+", but as "%20". This is done, because "+"-encoding is not
	 * compatible with `decodeURIComponent` and makes working with URL-encoded values manually painful.
	 * So here, "+" is just a normal, allowed URL-safe character and whitespace becomes "%20".
	 * Since `encode_uri` was never automatically applied in Rison, this should not break anything.
	 *
	 * @param {String} value - the value to escape problematic chars in
	 * @returns {String} uri-encoded string
	 *
	 * @example
	 * (new Urison()).escape('abc,:@')
	 * => 'abc%2C%3A%40'
	 */
	escape(value){
		value = `${value}`;

		if( /^[\-A-Za-z0-9~!*()_.',:@$\/+]*$/.test(value) ) return value;

		return replace(
			encodeURIComponent(value),
			['%2C', '%3A', '%40', '%24', '%2F', '%2B'],
			[',', ':', '@', '$', '/', '+']
		);
	}

}

export {Urison};