/**
 * This file contains Parsoid-independent JS helper functions.
 * Over time, more functions can be migrated out of various other files here.
 * @module
 */

'use strict';

require('../../core-upgrade.js');

var Promise = require('./promise.js');

var rejectMutation = function() {
	throw new TypeError("Mutation attempted on read-only collection.");
};

var lastItem = function(array) {
	console.assert(Array.isArray(array));
	return array[array.length - 1];
};

/** @namespace */
var JSUtils = {

	/**
	 * Return the last item in an array.
	 * @method
	 * @param {Array} array
	 * @return {any} The last item in `array`
	 */
	lastItem: lastItem,

	/**
	 * Return a {@link Map} with the same initial keys and values as the
	 * given {@link Object}.
	 * @param {Object} obj
	 * @return {Map}
	 */
	mapObject: function(obj) {
		return new Map(Object.entries(obj));
	},

	/**
	 * Return a two-way Map that maps each element to its index
	 * (and vice-versa).
	 * @param {Array} arr
	 * @return {Map}
	 */
	arrayMap: function(arr) {
		var m = new Map(arr.map(function(e, i) { return [e, i]; }));
		m.item = function(i) { return arr[i]; };
		return m;
	},

	/**
	 * ES6 maps/sets are still writable even when frozen, because they
	 * store data inside the object linked from an internal slot.
	 * This freezes a map by disabling the mutation methods, although
	 * it's not bulletproof: you could use `Map.prototype.set.call(m, ...)`
	 * to still mutate the backing store.
	 */
	freezeMap: function(it, freezeEntries) {
		// Allow `it` to be an iterable, as well as a map.
		if (!(it instanceof Map)) { it = new Map(it); }
		it.set = it.clear = it.delete = rejectMutation;
		Object.freeze(it);
		if (freezeEntries) {
			it.forEach(function(v, k) {
				JSUtils.deepFreeze(v);
				JSUtils.deepFreeze(k);
			});
		}
		return it;
	},

	/**
	 * This makes a set read-only.
	 * @see {@link .freezeMap}
	 */
	freezeSet: function(it, freezeEntries) {
		// Allow `it` to be an iterable, as well as a set.
		if (!(it instanceof Set)) { it = new Set(it); }
		it.add = it.clear = it.delete = rejectMutation;
		Object.freeze(it);
		if (freezeEntries) {
			it.forEach(function(v) {
				JSUtils.deepFreeze(v);
			});
		}
		return it;
	},

	/**
	 * Deep-freeze an object.
	 * {@link Map}s and {@link Set}s are handled with {@link .freezeMap} and
	 * {@link .freezeSet}.
	 * @see https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/freeze
	 * @param {any} o
	 * @return {any} Frozen object
	 */
	deepFreeze: function(o) {
		if (!(o instanceof Object)) {
			return o;
		} else if (Object.isFrozen(o)) {
			// Note that this might leave an unfrozen reference somewhere in
			// the object if there is an already frozen object containing an
			// unfrozen object.
			return o;
		} else if (o instanceof Map) {
			return JSUtils.freezeMap(o, true);
		} else if (o instanceof Set) {
			return JSUtils.freezeSet(o, true);
		}

		Object.freeze(o);
		for (var propKey in o) {
			var desc = Object.getOwnPropertyDescriptor(o, propKey);
			if ((!desc) || desc.get || desc.set) {
				// If the object is on the prototype or is a getter, skip it.
				continue;
			}
			// Recursively call deepFreeze.
			JSUtils.deepFreeze(desc.value);
		}
		return o;
	},

	/**
	 * Deep freeze an object, except for the specified fields.
	 * @param {Object} o
	 * @param {Object} ignoreFields
	 * @return {Object} Frozen object.
	 */
	deepFreezeButIgnore: function(o, ignoreFields) {
		for (var prop in o) {
			var desc = Object.getOwnPropertyDescriptor(o, prop);
			if (ignoreFields[prop] === true || (!desc) || desc.get || desc.set) {
				// Ignore getters, primitives, and explicitly ignored fields.
				return;
			}
			o[prop] = JSUtils.deepFreeze(desc.value);
		}
		Object.freeze(o);
	},

	/**
	 * Sort keys in an object, recursively, for better reproducibility.
	 * (This is especially useful before serializing as JSON.)
	 */
	sortObject: function(obj) {
		var sortObject = JSUtils.sortObject;
		var sortValue = function(v) {
			if (v instanceof Object) {
				return Array.isArray(v) ? v.map(sortValue) : sortObject(v);
			}
			return v;
		};
		return Object.keys(obj).sort().reduce(function(sorted, k) {
			sorted[k] = sortValue(obj[k]);
			return sorted;
		}, {});
	},

	/**
	 * Convert a counter to a Base64 encoded string.
	 * Padding is stripped. \,+ are replaced with _,- respectively.
	 * Warning: Max integer is 2^31 - 1 for bitwise operations.
	 */
	counterToBase64: function(n) {
		/* eslint-disable no-bitwise */
		var arr = [];
		do {
			arr.unshift(n & 0xff);
			n >>= 8;
		} while (n > 0);
		return (Buffer.from(arr))
			.toString("base64")
			.replace(/=/g, "")
			.replace(/\//g, "_")
			.replace(/\+/g, "-");
		/* eslint-enable no-bitwise */
	},

	/**
	 * Escape special regexp characters in a string.
	 * @param {string} s
	 * @return {string} A regular expression string that matches the
	 *  literal characters in s.
	 */
	escapeRegExp: function(s) {
		return s.replace(/[\^\\$*+?.()|{}\[\]\/]/g, '\\$&');
	},

	/**
	 * Escape special regexp characters in a string, returning a
	 * case-insensitive regular expression.  This is usually denoted
	 * by something like `(?i:....)` in most programming languages,
	 * but JavaScript doesn't support embedded regexp flags.
	 *
	 * @param {string} s
	 * @return {string} A regular expression string that matches the
	 *  literal characters in s.
	 */
	escapeRegExpIgnoreCase: function(s) {
		// Using Array.from() here ensures we split on unicode codepoints,
		// which may be longer than a single JavaScript character.
		return Array.from(s).map((c) => {
			if (/[\^\\$*+?.()|{}\[\]\/]/.test(c)) { return '\\' + c; }
			const uc = c.toUpperCase();
			const lc = c.toLowerCase();
			if (c === lc && c === uc) { return c; }
			if (uc.length === 1 && lc.length === 1) { return `[${uc}${lc}]`; }
			return `(?:${uc}|${lc})`;
		}).join('');
	},

	/**
	 * Join pieces of regular expressions together.  This helps avoid
	 * having to switch between string and regexp quoting rules, and
	 * can also give you a poor-man's version of the "x" flag, ie:
	 * ```
	 *  var re = rejoin( "(",
	 *      /foo|bar/, "|",
	 *      someRegExpFromAVariable
	 *      ")", { flags: "i" } );
	 * ```
	 * Note that this is basically string concatenation, except that
	 * regular expressions are converted to strings using their `.source`
	 * property, and then the final resulting string is converted to a
	 * regular expression.
	 *
	 * If the final argument is a regular expression, its flags will be
	 * used for the result.  Alternatively, you can make the final argument
	 * an object, with a `flags` property (as shown in the example above).
	 * @return {RegExp}
	 */
	rejoin: function() {
		var regexps = Array.from(arguments);
		var last = lastItem(regexps);
		var flags;
		if (typeof (last) === 'object') {
			if (last instanceof RegExp) {
				flags = /\/([gimy]*)$/.exec(last.toString())[1];
			} else {
				flags = regexps.pop().flags;
			}
		}
		return new RegExp(regexps.reduce(function(acc, r) {
			return acc + (r instanceof RegExp ? r.source : r);
		}, ''), flags === undefined ? '' : flags);
	},

	/**
	 * Append an array to an accumulator using the most efficient method
	 * available. Makes sure that accumulation is O(n).
	 */
	pushArray: function push(accum, arr) {
		if (accum.length < arr.length) {
			return accum.concat(arr);
		} else {
			// big accum & arr
			for (var i = 0, l = arr.length; i < l; i++) {
				accum.push(arr[i]);
			}
			return accum;
		}
	},

	/**
	 * Helper function to ease migration to Promise-based control flow
	 * (aka, "after years of wandering, arrive in the Promise land").
	 * This function allows retrofitting an existing callback-based
	 * method to return an equivalent Promise, allowing enlightened
	 * new code to omit the callback parameter and treat it as if
	 * it had an API which simply returned a Promise for the result.
	 *
	 * Sample use:
	 * ```
	 *   // callback is node-style: callback(err, value)
	 *   function legacyApi(param1, param2, callback) {
	 *     callback = JSUtils.mkPromised(callback); // THIS LINE IS NEW
	 *     ... some implementation here...
	 *     return callback.promise; // THIS LINE IS NEW
	 *   }
	 *   // old-style caller, still works:
	 *   legacyApi(x, y, function(err, value) { ... });
	 *   // new-style caller, such hotness:
	 *   return legacyApi(x, y).then(function(value) { ... });
	 * ```
	 * The optional `names` parameter to `mkPromised` is the same
	 * as the optional second argument to `Promise.promisify` in
	 * {@link https://github/cscott/prfun}.
	 * It allows the use of `mkPromised` for legacy functions which
	 * promise multiple results to their callbacks, eg:
	 * ```
	 *   callback(err, body, response);  // from npm "request" module
	 * ```
	 * For this callback signature, you have two options:
	 * 1. Pass `true` as the names parameter:
	 *    ```
	 *      function legacyRequest(options, callback) {
	 *        callback = JSUtils.mkPromised(callback, true);
	 *        ... existing implementation...
	 *        return callback.promise;
	 *      }
	 *    ```
	 *    This resolves the promise with the array `[body, response]`, so
	 *    a Promise-using caller looks like:
	 *    ```
	 *      return legacyRequest(options).then(function(r) {
	 *        var body = r[0], response = r[1];
	 *        ...
	 *      }
	 *    ```
	 *    If you are using `prfun` then `Promise#spread` is convenient:
	 *    ```
	 *      return legacyRequest(options).spread(function(body, response) {
	 *        ...
	 *      });
	 *    ```
	 * 2. Alternatively (and probably preferably), provide an array of strings
	 *    as the `names` parameter:
	 *    ```
	 *      function legacyRequest(options, callback) {
	 *        callback = JSUtils.mkPromised(callback, ['body','response']);
	 *        ... existing implementation...
	 *        return callback.promise;
	 *      }
	 *    ```
	 *    The resolved value will be an object with those fields:
	 *    ```
	 *      return legacyRequest(options).then(function(r) {
	 *        var body = r.body, response = r.response;
	 *        ...
	 *      }
	 *    ```
	 * Note that in both cases the legacy callback behavior is unchanged:
	 * ```
	 *   legacyRequest(options, function(err, body, response) { ... });
	 * ```
	 * @param {Function|undefined} callback
	 * @param {true|Array<string>} [names]
	 * @return {Function}
	 * @return {Promise} [return.promise] A promise that will be fulfilled
	 *  when the returned callback function is invoked.
	 */
	mkPromised: function(callback, names) {
		var res, rej;
		var p = new Promise(function(_res, _rej) { res = _res; rej = _rej; });
		var f = function(e, v) {
			if (e) {
				rej(e);
			} else if (names === true) {
				res(Array.prototype.slice.call(arguments, 1));
			} else if (names) {
				var value = {};
				for (var index in names) {
					value[names[index]] = arguments[(+index) + 1];
				}
				res(value);
			} else {
				res(v);
			}
			return callback && callback.apply(this, arguments);
		};
		f.promise = p;
		return f;
	},

	/**
	 * Determine whether two objects are identical, recursively.
	 * @param {any} a
	 * @param {any} b
	 * @return {boolean}
	 */
	deepEquals: function(a, b) {
		var i;
		if (a === b) {
			// If only it were that simple.
			return true;
		}

		if (a === undefined || b === undefined ||
				a === null || b === null) {
			return false;
		}

		if (a.constructor !== b.constructor) {
			return false;
		}

		if (a instanceof Object) {
			for (i in a) {
				if (!this.deepEquals(a[i], b[i])) {
					return false;
				}
			}
			for (i in b) {
				if (a[i] === undefined) {
					return false;
				}
			}
			return true;
		}

		return false;
	},

	/**
	 * Return accurate system time
	 * @return {number}
	 */
	startTime: function() {
		var startHrTime = process.hrtime();
		var milliseconds = (startHrTime[0] * 1e9 + startHrTime[1]) / 1000000;	// convert seconds and nanoseconds to a scalar milliseconds value
		return milliseconds;
	},

	/**
	 * Return millisecond accurate system time differential
	 * @param {number} previousTime
	 * @return {number}
	 */
	elapsedTime: function(previousTime) {
		var endHrTime = process.hrtime();
		var milliseconds = (endHrTime[0] * 1e9 + endHrTime[1]) / 1000000;	// convert seconds and nanoseconds to a scalar milliseconds value
		return milliseconds - previousTime;
	},

};

if (typeof module === "object") {
	module.exports.JSUtils = JSUtils;
}