/* global Symbol */
// URL Polyfill
// Draft specification: https://url.spec.whatwg.org

// Notes:
// - Primarily useful for parsing URLs and modifying query parameters
// - Should work in IE8+ and everything more modern, with es5.js polyfills

(function (global) {
	'use strict';

	function isSequence(o) {
		if (!o) return false;
		if ('Symbol' in global && 'iterator' in global.Symbol &&
				typeof o[Symbol.iterator] === 'function') return true;
		if (Array.isArray(o)) return true;
		return false;
	}

	;(function() { // eslint-disable-line no-extra-semi

		// Browsers may have:
		// * No global URL object
		// * URL with static methods only - may have a dummy constructor
		// * URL with members except searchParams
		// * Full URL API support
		var origURL = global.URL;
		var nativeURL;
		try {
			if (origURL) {
				nativeURL = new global.URL('http://example.com');
				if ('searchParams' in nativeURL) {
					var url = new URL('http://example.com');
					url.search = 'a=1&b=2';
					if (url.href === 'http://example.com/?a=1&b=2') {
						url.search = '';
						if (url.href === 'http://example.com/') {
							return;
						}
					}
				}
				if (!('href' in nativeURL)) {
					nativeURL = undefined;
				}
				nativeURL = undefined;
			}
		// eslint-disable-next-line no-empty
		} catch (_) {}

		// NOTE: Doesn't do the encoding/decoding dance
		function urlencoded_serialize(pairs) {
			var output = '', first = true;
			pairs.forEach(function (pair) {
				var name = encodeURIComponent(pair.name);
				var value = encodeURIComponent(pair.value);
				if (!first) output += '&';
				output += name + '=' + value;
				first = false;
			});
			return output.replace(/%20/g, '+');
		}

		// https://url.spec.whatwg.org/#percent-decode
		var cachedDecodePattern;
		function percent_decode(bytes) {
			// This can't simply use decodeURIComponent (part of ECMAScript) as that's limited to
			// decoding to valid UTF-8 only. It throws URIError for literals that look like percent
			// encoding (e.g. `x=%`, `x=%a`, and `x=a%2sf`) and for non-UTF8 binary data that was
			// percent encoded and cannot be turned back into binary within a JavaScript string.
			//
			// The spec deals with this as follows:
			// * Read input as UTF-8 encoded bytes. This needs low-level access or a modern
			//   Web API, like TextDecoder. Old browsers don't have that, and it'd a large
			//   dependency to add to this polyfill.
			// * For each percentage sign followed by two hex, blindly decode the byte in binary
			//   form. This would require TextEncoder to not corrupt multi-byte chars.
			// * Replace any bytes that would be invalid under UTF-8 with U+FFFD.
			//
			// Instead we:
			// * Use the fact that UTF-8 is designed to make validation easy in binary.
			//   You don't have to decode first. There are only a handful of valid prefixes and
			//   ranges, per RFC 3629. <https://datatracker.ietf.org/doc/html/rfc3629#section-3>
			// * Safely create multi-byte chars with decodeURIComponent, by only passing it
			//   valid and full characters (e.g. "%F0" separately from "%F0%9F%92%A9" throws).
			//   Anything else is kept as literal or replaced with U+FFFD, as per the URL spec.

			if (!cachedDecodePattern) {
				// In a UTF-8 multibyte sequence, non-initial bytes are always between %80 and %BF
				var uContinuation = '%[89AB][0-9A-F]';

				// The length of a UTF-8 sequence is specified by the first byte
				//
				// One-byte sequences: 0xxxxxxx
				// So the byte is between %00 and %7F
				var u1Bytes = '%[0-7][0-9A-F]';
				// Two-byte sequences: 110xxxxx 10xxxxxx
				// So the first byte is between %C0 and %DF
				var u2Bytes = '%[CD][0-9A-F]' + uContinuation;
				// Three-byte sequences: 1110xxxx 10xxxxxx 10xxxxxx
				// So the first byte is between %E0 and %EF
				var u3Bytes = '%E[0-9A-F]' + uContinuation + uContinuation;
				// Four-byte sequences: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
				// So the first byte is between %F0 and %F7
				var u4Bytes = '%F[0-7]' + uContinuation + uContinuation +uContinuation;

				var anyByte = '%[0-9A-F][0-9A-F]';

				// Match some consecutive percent-escaped bytes. More precisely, match
				// 1-4 bytes that validly encode one character in UTF-8, or 1 byte that
				// would be invalid in UTF-8 in this location.
				cachedDecodePattern = new RegExp(
					'(' + u4Bytes + ')|(' + u3Bytes + ')|(' + u2Bytes + ')|(' + u1Bytes + ')|(' + anyByte + ')',
					'gi'
				);
			}

			return bytes.replace(cachedDecodePattern, function (match, u4, u3, u2, u1, uBad) {
				return (uBad !== undefined) ? '\uFFFD' : decodeURIComponent(match);
			});
		}

		// NOTE: Doesn't do the encoding/decoding dance
		//
		// https://url.spec.whatwg.org/#concept-urlencoded-parser
		function urlencoded_parse(input, isindex) {
			var sequences = input.split('&');
			if (isindex && sequences[0].indexOf('=') === -1)
				sequences[0] = '=' + sequences[0];
			var pairs = [];
			sequences.forEach(function (bytes) {
				if (bytes.length === 0) return;
				var index = bytes.indexOf('=');
				if (index !== -1) {
					var name = bytes.substring(0, index);
					var value = bytes.substring(index + 1);
				} else {
					name = bytes;
					value = '';
				}
				name = name.replace(/\+/g, ' ');
				value = value.replace(/\+/g, ' ');
				pairs.push({ name: name, value: value });
			});
			var output = [];
			pairs.forEach(function (pair) {
				output.push({
					name: percent_decode(pair.name),
					value: percent_decode(pair.value)
				});
			});
			return output;
		}

		function URLUtils(url) {
			if (nativeURL)
				return new origURL(url);
			var anchor = document.createElement('a');
			anchor.href = url;
			return anchor;
		}

		function URLSearchParams(init) {
			var $this = this;
			this._list = [];

			if (init === undefined || init === null) {
				// no-op
			} else if (init instanceof URLSearchParams) {
				// In ES6 init would be a sequence, but special case for ES5.
				this._list = urlencoded_parse(String(init));
			} else if (typeof init === 'object' && isSequence(init)) {
				Array.from(init).forEach(function(e) {
					if (!isSequence(e)) throw TypeError();
					var nv = Array.from(e);
					if (nv.length !== 2) throw TypeError();
					$this._list.push({name: String(nv[0]), value: String(nv[1])});
				});
			} else if (typeof init === 'object' && init) {
				Object.keys(init).forEach(function(key) {
					$this._list.push({name: String(key), value: String(init[key])});
				});
			} else {
				init = String(init);
				if (init.substring(0, 1) === '?')
					init = init.substring(1);
				this._list = urlencoded_parse(init);
			}

			this._url_object = null;
			this._setList = function (list) { if (!updating) $this._list = list; };

			var updating = false;
			this._update_steps = function() {
				if (updating) return;
				updating = true;

				if (!$this._url_object) return;

				// Partial workaround for IE issue with 'about:'
				if ($this._url_object.protocol === 'about:' &&
						$this._url_object.pathname.indexOf('?') !== -1) {
					$this._url_object.pathname = $this._url_object.pathname.split('?')[0];
				}

				$this._url_object.search = urlencoded_serialize($this._list);

				updating = false;
			};
		}


		Object.defineProperties(URLSearchParams.prototype, {
			append: {
				value: function (name, value) {
					this._list.push({ name: name, value: value });
					this._update_steps();
				}, writable: true, enumerable: true, configurable: true
			},

			'delete': {
				value: function (name) {
					for (var i = 0; i < this._list.length;) {
						if (this._list[i].name === name)
							this._list.splice(i, 1);
						else
							++i;
					}
					this._update_steps();
				}, writable: true, enumerable: true, configurable: true
			},

			get: {
				value: function (name) {
					for (var i = 0; i < this._list.length; ++i) {
						if (this._list[i].name === name)
							return this._list[i].value;
					}
					return null;
				}, writable: true, enumerable: true, configurable: true
			},

			getAll: {
				value: function (name) {
					var result = [];
					for (var i = 0; i < this._list.length; ++i) {
						if (this._list[i].name === name)
							result.push(this._list[i].value);
					}
					return result;
				}, writable: true, enumerable: true, configurable: true
			},

			has: {
				value: function (name) {
					for (var i = 0; i < this._list.length; ++i) {
						if (this._list[i].name === name)
							return true;
					}
					return false;
				}, writable: true, enumerable: true, configurable: true
			},

			set: {
				value: function (name, value) {
					var found = false;
					for (var i = 0; i < this._list.length;) {
						if (this._list[i].name === name) {
							if (!found) {
								this._list[i].value = value;
								found = true;
								++i;
							} else {
								this._list.splice(i, 1);
							}
						} else {
							++i;
						}
					}

					if (!found)
						this._list.push({ name: name, value: value });

					this._update_steps();
				}, writable: true, enumerable: true, configurable: true
			},

			entries: {
				value: function() { return new Iterator(this._list, 'key+value'); },
				writable: true, enumerable: true, configurable: true
			},

			keys: {
				value: function() { return new Iterator(this._list, 'key'); },
				writable: true, enumerable: true, configurable: true
			},

			values: {
				value: function() { return new Iterator(this._list, 'value'); },
				writable: true, enumerable: true, configurable: true
			},

			forEach: {
				value: function(callback) {
					var thisArg = (arguments.length > 1) ? arguments[1] : undefined;
					this._list.forEach(function(pair) {
						callback.call(thisArg, pair.value, pair.name);
					});

				}, writable: true, enumerable: true, configurable: true
			},

			toString: {
				value: function () {
					return urlencoded_serialize(this._list);
				}, writable: true, enumerable: false, configurable: true
			},

			sort: {
				value: function sort() {
					var entries = this.entries();
					var entry = entries.next();
					var keys = [];
					var values = {};

					while (!entry.done) {
						var value = entry.value;
						var key = value[0];
						keys.push(key);
						if (!(Object.prototype.hasOwnProperty.call(values, key))) {
							values[key] = [];
						}
						values[key].push(value[1]);
						entry = entries.next();
					}

					keys.sort();
					for (var i = 0; i < keys.length; i++) {
						this["delete"](keys[i]);
					}
					for (var j = 0; j < keys.length; j++) {
						key = keys[j];
						this.append(key, values[key].shift());
					}
				}
			}
		});

		function Iterator(source, kind) {
			var index = 0;
			this.next = function() {
				if (index >= source.length)
					return {done: true, value: undefined};
				var pair = source[index++];
				return {done: false, value:
								kind === 'key' ? pair.name :
								kind === 'value' ? pair.value :
								[pair.name, pair.value]};
			};
		}

		if ('Symbol' in global && 'iterator' in global.Symbol) {
			Object.defineProperty(URLSearchParams.prototype, global.Symbol.iterator, {
				value: URLSearchParams.prototype.entries,
				writable: true, enumerable: true, configurable: true});
			Object.defineProperty(Iterator.prototype, global.Symbol.iterator, {
				value: function() { return this; },
				writable: true, enumerable: true, configurable: true});
		}

		function URL(url, base) {
			if (!(this instanceof global.URL))
				throw new TypeError("Failed to construct 'URL': Please use the 'new' operator.");

			if (base) {
				url = (function () {
					if (nativeURL) return new origURL(url, base).href;
					var iframe;
					try {
						var doc;
						// Use another document/base tag/anchor for relative URL resolution, if possible
						if (Object.prototype.toString.call(window.operamini) === "[object OperaMini]") {
							iframe = document.createElement('iframe');
							iframe.style.display = 'none';
							document.documentElement.appendChild(iframe);
							doc = iframe.contentWindow.document;
						} else if (document.implementation && document.implementation.createHTMLDocument) {
							doc = document.implementation.createHTMLDocument('');
						} else if (document.implementation && document.implementation.createDocument) {
							doc = document.implementation.createDocument('http://www.w3.org/1999/xhtml', 'html', null);
							doc.documentElement.appendChild(doc.createElement('head'));
							doc.documentElement.appendChild(doc.createElement('body'));
						} else if (window.ActiveXObject) {
							doc = new window.ActiveXObject('htmlfile');
							doc.write('<head></head><body></body>');
							doc.close();
						}

						if (!doc) throw Error('base not supported');

						var baseTag = doc.createElement('base');
						baseTag.href = base;
						doc.getElementsByTagName('head')[0].appendChild(baseTag);
						var anchor = doc.createElement('a');
						anchor.href = url;
						return anchor.href;
					} finally {
						if (iframe)
							iframe.parentNode.removeChild(iframe);
					}
				}());
			}

			// An inner object implementing URLUtils (either a native URL
			// object or an HTMLAnchorElement instance) is used to perform the
			// URL algorithms. With full ES5 getter/setter support, return a
			// regular object For IE8's limited getter/setter support, a
			// different HTMLAnchorElement is returned with properties
			// overridden

			var instance = URLUtils(url || '');

			// Detect for ES5 getter/setter support
			// (an Object.defineProperties polyfill that doesn't support getters/setters may throw)
			var ES5_GET_SET = (function() {
				if (!('defineProperties' in Object)) return false;
				try {
					var obj = {};
					Object.defineProperties(obj, { prop: { get: function () { return true; } } });
					return obj.prop;
				} catch (_) {
					return false;
				}
			}());

			var self = ES5_GET_SET ? this : document.createElement('a');



			var query_object = new URLSearchParams(
				instance.search ? instance.search.substring(1) : null);
			query_object._url_object = self;

			Object.defineProperties(self, {
				href: {
					get: function () { return instance.href; },
					set: function (v) { instance.href = v; tidy_instance(); update_steps(); },
					enumerable: true, configurable: true
				},
				origin: {
					get: function () {
						if (this.protocol.toLowerCase() === "data:") {
							return null
						}

						if ('origin' in instance) return instance.origin;
						return this.protocol + '//' + this.host;
					},
					enumerable: true, configurable: true
				},
				protocol: {
					get: function () { return instance.protocol; },
					set: function (v) { instance.protocol = v; },
					enumerable: true, configurable: true
				},
				username: {
					get: function () { return instance.username; },
					set: function (v) { instance.username = v; },
					enumerable: true, configurable: true
				},
				password: {
					get: function () { return instance.password; },
					set: function (v) { instance.password = v; },
					enumerable: true, configurable: true
				},
				host: {
					get: function () {
						// IE returns default port in |host|
						var re = {'http:': /:80$/, 'https:': /:443$/, 'ftp:': /:21$/}[instance.protocol];
						return re ? instance.host.replace(re, '') : instance.host;
					},
					set: function (v) { instance.host = v; },
					enumerable: true, configurable: true
				},
				hostname: {
					get: function () { return instance.hostname; },
					set: function (v) { instance.hostname = v; },
					enumerable: true, configurable: true
				},
				port: {
					get: function () { return instance.port; },
					set: function (v) { instance.port = v; },
					enumerable: true, configurable: true
				},
				pathname: {
					get: function () {
						// IE does not include leading '/' in |pathname|
						if (instance.pathname.charAt(0) !== '/') return '/' + instance.pathname;
						return instance.pathname;
					},
					set: function (v) { instance.pathname = v; },
					enumerable: true, configurable: true
				},
				search: {
					get: function () { return instance.search; },
					set: function (v) {
						if (instance.search === v) return;
						instance.search = v; tidy_instance(); update_steps();
					},
					enumerable: true, configurable: true
				},
				searchParams: {
					get: function () { return query_object; },
					enumerable: true, configurable: true
				},
				hash: {
					get: function () { return instance.hash; },
					set: function (v) { instance.hash = v; tidy_instance(); },
					enumerable: true, configurable: true
				},
				toString: {
					value: function() { return instance.toString(); },
					enumerable: false, configurable: true
				},
				valueOf: {
					value: function() { return instance.valueOf(); },
					enumerable: false, configurable: true
				}
			});

			function tidy_instance() {
				var href = instance.href.replace(/#$|\?$|\?(?=#)/g, '');
				if (instance.href !== href)
					instance.href = href;
			}

			function update_steps() {
				query_object._setList(instance.search ? urlencoded_parse(instance.search.substring(1)) : []);
				query_object._update_steps();
			}

			return self;
		}

		if (origURL) {
			for (var i in origURL) {
				if (Object.prototype.hasOwnProperty.call(origURL, i) && typeof origURL[i] === 'function')
					URL[i] = origURL[i];
			}
		}

		global.URL = URL;
		global.URLSearchParams = URLSearchParams;
	})();

	// Patch native URLSearchParams constructor to handle sequences/records
	// if necessary.
	(function() {
		if (new global.URLSearchParams([['a', 1]]).get('a') === '1' &&
				new global.URLSearchParams({a: 1}).get('a') === '1')
			return;
		var orig = global.URLSearchParams;
		global.URLSearchParams = function(init) {
			if (init && typeof init === 'object' && isSequence(init)) {
				var o = new orig();
				Array.from(init).forEach(function (e) {
					if (!isSequence(e)) throw TypeError();
					var nv = Array.from(e);
					if (nv.length !== 2) throw TypeError();
					o.append(nv[0], nv[1]);
				});
				return o;
			} else if (init && typeof init === 'object') {
				o = new orig();
				Object.keys(init).forEach(function(key) {
					o.set(key, init[key]);
				});
				return o;
			} else {
				return new orig(init);
			}
		};
	})();

}(self));
