93Akkord / akkd-common

// ==UserScript==

// #region Info

// @namespace   https://openuserjs.org/users/93Akkord
// @exclude     *
// @author      Michael Barros (https://openuserjs.org/users/93Akkord)
// @icon        data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAYdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuNvyMY98AAA5DSURBVGhDtVr5T1zXGeVviPJDq/bHNq2ZN8MyrGEx4C2JY6tqo+SXRGoVKYqqSlXaqFUjNUlTRXWUWE5iXC/YmNWAjdmXGRgDNruNYciwG4aBYR0wY+x4AWbe1/PdN282PwO21HGO333bfed893zfve85Efx7q6zrpd9UDX32er295bDZ2f6GeVbgdZMT4G1Q2+zwbWfbD6F9SGydvu1s+0GcU3HAh5B2fTgc7QeALLSzGmfbM4GsRuwD+8RWOZaB84y06smW/RUTnx7MMb0kyB860/jT1xrvjO+3LFGWZZmymhcps3kB4C3vL9E+XzsT7UzLImU0LVEGjqVb5gFscVy0cV9a0wKlmucFXjUvUopZ2ed2MpCE/cRwmOYpqWGREoGERmwbcaxxnuJxPN60II7FNywIxKFtNK1Qaq19ZG92608iDtYO5WSBRNa1ZcpgQEQGxDCYNO/vBQTJHZAGpEIckw1HcpMiIBmEkkxLfiSCEBPUQrwpgDiIMjYuUYxpmWLNAO5NLx/PicismZrLsLhE9DMF+TABwN4wolpIw4ikgmTKjgIwCir55xKAyPsEGCGAkVA1MRuRWe9wZzSv+CzyNIRdAC3SwUiFfVJgHy3yDFVAcPS1SAcjOPpxIM8CYnkLEXEQEFcz5Y7IUgXARsLjKnGArbOb6DPYOikgqUWeoWUhLdKM4MirApi8H75RSKix7yxAi6wWhIAw0sF4UQFK9BeUyAcJ4DxIqMYI7K+3uzObXX7iDBH5XZIX3od9tEgzBHGAq08S9lXyuxGgkg8XwO0Y3B9fBQH7ICDDJ2C3CasiDYme6kteLfIMvwAQEsCx5xPA20VBWkWMELAMAWyhBghAFRKR1yC5HRQBin20yDMCApg0C9i9hQICAuQDgIAKhxtVaNK9F+S1CD4LPFmlgdTufL/w3LVfbW8nwCgEzLyoALXmaxNXoQgAOPLPIUAAbVWA6vtQAZwDYgSmnluAv2Q28RJh+wROMgeIP5cAgAUYcSycPCewP4l3OwK7tQ1DjbwgzFuI1CKrBZW4gkDV0RYwqQhI36UAts12FUfF/1uACiEgYwcBXGlEtQmxzdOkVTB5tR1sGy2ywQi3DkOLdDB8AqZ2ELCMycoF8iwCiauuebD/qtlFqajHDEGaI++LfrKPeDLO8Qgkm+boVZBKQvVIwHGxkGNhPtJCBKCQ5y28j+MxAFecuIYlHzAL7yRA+B1E0zFJHb3uoqNtynkeAbFo45nX5KTqmfv0w+ojKpx0UXLDrCiZwXVeRB4l9JOBVbLefUgDdx/RG/UOsfYPnH+WAJDEvTF4L/hL3yIN4l6+/6jJIcizMDGRZWAtxETTEWWOdBomNcUu8/Rep5Mey0SbRPRp7xyi5xTR57qeXDdLI2uPCaepY85FUUUDlFg/I84FC0isX6DjtmXy4rot4LXLt8hYOY3VpM8+PgEKEGGQM6LN5IWIeid9YV0Uz9nEX0eqhshYNYXzeNkRAuocbl7PpGEU0ppgB0EeD4fyCxNruE35tcy4yFDSBxs4QRJvULUzNIyICAFOF0m5PaQrGaSEBuQIHpwMCFJ1c/Q1CPDPAxws7SF98QAZq+/4vK++dYE87BYHESwgxjwHu6CMVjvo84EFkmUvbeFhh8ut4DFEUTXTEIAyml7rcLM1uDSqEH6uddD4+gYJhsD61hal5neQvnQIr34zlIQOhlYf+gXoLnSRruA2RV8aJIwq7aufpfRGPKTGTl/1z4lOPMCB4i4I6KOokn5KqR7DtTOUiWuT6+cFcUY8bJTR6KDfWpy0t2qE/tXH92ME8bAjLOCSjfZcGaPYKyPuiFQI4MkoFTf6yaODd6/N0hMZj8RNMv7y4s/HljGKzMPDy2yUUDFOw34By6Q/30VSwS26MOKkex6Z3F6ZTlqn5JjSH+ir27O4DhEUAjrJUNhLHzQN0+qml9bRwa3V+5RZOYJknaFUiM8bWxV98PXr2I6sPRAC0KQ3Lw+QoXgQAoZJX2LVFpCAhDw94hKPnFt/IPc55mXCEJqmFkm62CmISgU3aWjlR3QrU+fsMkWfa6Fsq528uI5RPmz3Rp+7Lu+52EvHeibFdTwC+ws76L2afnI/AT0Exu5+KB8qaJP1Rf1kuDJEZ8dWxHUcuU3gsdcr9jlQqoCoYivpcG2IABF5QCQhhn3kHicoiFinvMeuDcgIKN19vEnJBbBAQQ9F5naSzXWfA0NdM4t0vGcMyc53EJlGZ7zRZ8xe6WIHRV7spv90T4jrtkDoT9W95EI/TMZ1/0f5SH6rR3+xiyIL+yi1tJ/WNjhTvDSwuCof/G/lVkZ29Vbr9Dx3GyRgkKTwEVAFcPK+Y3HAPmwamf5Y2e19M9dCG2wjJtAE5XndpDvfTkOude6X7m8wITwDD+hyLHhjTpm8ugsdZMB1Ul4PHWMBOIf/aH1DifzKoyf0O5CXzt/AaPaQVHSLPjSNKpbFk/9ce1OWTlso8vQ1+mfrkDiuCtAjz0IF8MTE0cfCK7HBSdlDKFu4YxOkznUOyd/esNH9TcQXHVSMz5H+QjtJOTdo2HVPYeX78UAX2ablyJxWIdKQ30sScKxLGQH/D/c43A8oLbcdOYXrim+LxP779SnRHY/B0bJ20uXewEi30+cdd8RtqgAJ1U4qH0YyswCU0RSUUfY/z5BJSCKb+yFGUYzaU7/5xxuUgI6lnOs0vBwQ8ACjwKI9wD/aRklf2ENRhUquHOtWCPCIPsQIsJWYZOvsKkXhfNQlK2zRTx9fGxfHeb54pwLFIh8lN6+XvujkHFIEHLliJQmFgUdACEiBgGSenCCAZ8K3mh2wC5OXaXzprrdzet7TMb3gsc6tIA0Uj79fP0i6nDayCQEyTbrW5H0ny7Ym7q7jTpnWUF3erkC1KLqJfMEIqAJA4PdFTVv5N0fZjejLS9+hQhlAngW8c7WPNvgp6OWbW1MkIS+kgn46dtMu4sTC3kQZVQQgSEJArd0tpn/YJ75+jk7YlElnCx0dPGP26I5XyhKQ8F29d+0Jz8lEpaNOMpxtoUEIYEkd9gV5z7d19HbhNe/DTXgcKifcP1JKaS9s1OXPAa7j+842yTHf18jWxRXWQI/wnA8tmJiK+siIwjDkWhMjeW/TQ6dQ1U72zdLqI8xH6IAFvHEVFajUBgFjsBIEvFrjcCci+rywiqudxprlEXOkkZV78p7sGvr1KQvtQTL9KtuMMqpMKAsPNsh45poQwKW2bXaJ9KfMpMu20Il2m5dtxOSq7asUndtLxzkHcIAtcCDvOhlON9ORglavSGiInQfBQ1d6hNh3r/TK95BvXDp5xGTctIX5iIsp93u4oh8jgAQun8CkOsgCZhQBsM/euikqGJjyFg9MeP5ad9OrQ4XQ53aRgcscqs77Vb1ykXXSkzcw6dmfd0P+un3YW2S94/mybcgbhZzQc0U53SSf7B7xXMJ1BcDh8pvyH6r75IKBO558XJuc3ylK6x4Ugg9rur3FuKbIOuX5pGVYjobn9+R10eGCZk9J/4Snd8blvYrzH1V0eopwfyHa6aWw1eWxYAEOWMj3RRjrm1isM3TnWlEmQQYPYg9HFcLLXFEuYBI710bS2TYyMGG0BXLacR4Jh4SMhGAujXq+5hy2EC/BGhKukc53wNPoB8mtB6IwexvOtWOL80WwGy8xYCUpF8/F9bqTzbLhdIvvOddxXTdyhf0P+6gCkqodbl4RipUh1iOxdQ4ylo+L5NEjicQMiQQzFN0WFUWX302RIKsTwHyALR/n83ydHlsdCwFRHSch9wHouCJha8BCzoA6rkcp5L6lQvQL6EtwHMlpgL/1l/g4RBbwvXwfAghxhhL4//II6Zjf1Tt8vTsisdoOAYFXPv4Gn9CAlSAWayhTYuXI4AdzpeCaLUgIoHpcwnEug6WDAYCcAYgqsQE/iGOiLyZ9GRNhmQ9oS1hX6QDDZZRFkNNzdbk8inYopCsgLcDRHyd9BQSUQUAS1tQJsI8qIBjGOqdIGAkkWL0gBiIKuA1ygmQo9BzFcDBxkNQzySBwOZTKgwmGgqOtQro6IWBA9PUVkwEB7H8tAQLICz1EcESVIeaIMnFt8gYghDiiKyDIKwKkYAgBiKoGeYEwARx5Ju8XkLyTAMCIty8DD7mPlBZxRjh5cY+IPCMo6oDwMrfZElrEVfjJB4iHCAjPAUbgFS8AI146+MHhAjjpGE8LCCUdTpyhLwN5lERN4oAg7o98KPkQAeo/pG0nIN48T3F1sBMeHC5AFaW2BUT0txOAZBUCQkkHI9j3OwrQJB0E9VuNERUqumyEohFhRihpIMwuKoIjL5J2F4mrZRuByiky4MU+uszmjkjAi3GwAIWo8nUgFPx9RkE8EjsKQ294UQHC99rW2ck2AhCgDwjgEQiNsraAoM98EByDnIjiqvLCAkKJq9jJNgLBAuIr7G7lQxIT931Q2iWi6+ZBCGR4rmCUhdb5ENLCNtsTV8kHl8pQgHiFHQKmIWAaAoYDApgQR3i7j6laMNY78YINYiAvITe0BajkdxP57cj7UAkRVXYIGIWFKibmOPoxjfzNMfDdcdeA9aKR2KJec2V5SsAosLvIP5s8A6SFALQrAVgo5pLVGRF9sTc3FgKYPP/jmSbJnYCciMVkJ10FEV+F4TWNUmmeTV7Ab5tgsloICJCqsV/lwOr0xvmIX/zt2M+ji/sn+csv/6OBMgrPCb6PRcBO+gpE0i9gB/IMLIt3K0ACDLBOFPwfe773zi8/Ov4z8X+svPLBNy8bjtd8GZXbbonJ7+18EURf7OrRn2m16k+1WA3Zptv6E3WD0vfm29KJepv03dPQAZHfY3uywSadbNwWOgGTLTK72Rp9utUifVP571c++OzliIiIiP8B4VfIttF+iOkAAAAASUVORK5CYII=

// #endregion Info

// ==UserLibrary==

// @name        akkd-common
// @description Common functions
// @copyright   2022+, Michael Barros (https://openuserjs.org/users/93Akkord)
// @license     CC-BY-NC-SA-4.0; https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode
// @license     GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0.txt
// @version     0.0.19

// ==/UserScript==

// ==/UserLibrary==

// ==OpenUserJS==

// @author      93Akkord

// ==/OpenUserJS==

/// <reference path='C:/Users/mbarros/Documents/DevProjects/Web/Tampermonkey/Palantir/@types/__fullReferencePaths__.js' />

/*

# akkd-common

A collection of commonly used classes and functions.

## Required requires:

- https://code.jquery.com/jquery-3.2.1.min.js
- https://openuserjs.org/src/libs/93Akkord/loglevel.js

*/

// #region Events

// Setup location change events
/**
 *
 * Example usage:
 * ```javascript
 * window.addEventListener('popstate', () => {
 *     window.dispatchEvent(new Event('locationchange'));
 * });
 */
(() => {
    class LocationChangeEvent extends Event {
        constructor(type, prevUrl, newUrl) {
            super(type);

            this.prevUrl = prevUrl;
            this.newUrl = newUrl;
        }
    }

    let prevUrl = document.location.href;
    let oldPushState = history.pushState;

    history.pushState = function pushState() {
        let ret = oldPushState.apply(this, arguments);
        let newUrl = document.location.href;

        window.dispatchEvent(new LocationChangeEvent('pushstate', prevUrl, newUrl));
        window.dispatchEvent(new LocationChangeEvent('locationchange', prevUrl, newUrl));

        prevUrl = newUrl;

        return ret;
    };

    let oldReplaceState = history.replaceState;

    history.replaceState = function replaceState() {
        let ret = oldReplaceState.apply(this, arguments);
        let newUrl = document.location.href;

        window.dispatchEvent(new LocationChangeEvent('replacestate', prevUrl, newUrl));
        window.dispatchEvent(new LocationChangeEvent('locationchange', prevUrl, newUrl));

        prevUrl = newUrl;

        return ret;
    };

    window.addEventListener('popstate', () => {
        let newUrl = document.location.href;

        window.dispatchEvent(new LocationChangeEvent('locationchange', prevUrl, newUrl));

        prevUrl = newUrl;
    });
})();

// #endregion Events

// #region Helper Classes

class Logger {
    /**
     * Creates an instance of Logger.
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {Window} _window
     * @param {string | null} devTag
     * @memberof Logger
     */
    constructor(_window = null, devTag = null) {
        /**
         * @type {Window}
         * @private
         */
        this.window = _window || getWindow();

        /** @type {string | null} */
        this.devTag = devTag;

        /** @type {string[]} */
        this._additionalTags = [];
    }

    /**
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @type {string[]}
     * @public
     * @memberof Logger
     */
    get additionalTags() {
        return this._additionalTags;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {string[]} value
     * @memberof Logger
     */
    set additionalTags(value) {
        if (getType(value) != 'array') {
            value = [value];
        }

        this._additionalTags = value;
    }

    /** @type {string} */
    get label() {
        return [].concat([this.devTag], this.additionalTags).join(' ');
    }

    /** @type {(...data: any[]) => void} */
    get log() {
        if (this.devTag) {
            return console.log.bind(console, `${this.label}`);
        } else {
            return console.log.bind(console);
        }
    }

    /** @type {(...data: any[]) => void} */
    get info() {
        if (this.devTag) {
            return console.info.bind(console, `${this.label}`);
        } else {
            return console.info.bind(console);
        }
    }

    /** @type {(...data: any[]) => void} */
    get error() {
        if (this.devTag) {
            return console.error.bind(console, `${this.label}`);
        } else {
            return console.error.bind(console);
        }
    }

    /** @type {(...data: any[]) => void} */
    get debug() {
        if (this.devTag) {
            return console.debug.bind(console, `${this.label}`);
        } else {
            return console.debug.bind(console);
        }
    }

    /** @type {(...data: any[]) => void} */
    get warn() {
        if (this.devTag) {
            return console.warn.bind(console, `${this.label}`);
        } else {
            return console.warn.bind(console);
        }
    }

    /**
     * Maybe use later?
     *
     * @memberof Logger
     */
    _setupFunctions() {
        let self = this;
        let funcs = ['log', 'info', 'error', 'debug', 'warn'];

        for (let i = 0; i < funcs.length; i++) {
            let func = funcs[i];

            self[func] = function () {
                let args = [...arguments];

                if (self.devTag) args.unshift(self.label);

                self.window.console.debug.bind(self.window.console, ...args);
            };
        }
    }
}

class Base64 {
    static keyStr = 'ABCDEFGHIJKLMNOP' + 'QRSTUVWXYZabcdef' + 'ghijklmnopqrstuv' + 'wxyz0123456789+/' + '=';

    /**
     *
     *
     * @static
     * @param {string} input
     * @returns {string}
     * @memberof Base64
     */
    static encode(input) {
        input = escape(input);

        let output = '';

        let chr1;
        let chr2;
        let chr3 = '';

        let enc1;
        let enc2;
        let enc3;
        let enc4 = '';

        let i = 0;

        do {
            chr1 = input.charCodeAt(i++);
            chr2 = input.charCodeAt(i++);
            chr3 = input.charCodeAt(i++);
            enc1 = chr1 >> 2;
            enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
            enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
            enc4 = chr3 & 63;

            if (isNaN(chr2)) {
                enc3 = enc4 = 64;
            } else if (isNaN(chr3)) {
                enc4 = 64;
            }

            output = output + Base64.keyStr.charAt(enc1) + Base64.keyStr.charAt(enc2) + Base64.keyStr.charAt(enc3) + Base64.keyStr.charAt(enc4);
            chr1 = chr2 = chr3 = '';
            enc1 = enc2 = enc3 = enc4 = '';
        } while (i < input.length);

        return output;
    }

    /**
     *
     *
     * @static
     * @param {string} input
     * @returns {string}
     * @memberof Base64
     */
    static decode(input) {
        let output = '';

        let chr1;
        let chr2;
        let chr3 = '';

        let enc1;
        let enc2;
        let enc3;
        let enc4 = '';

        let i = 0;

        let base64test = /[^A-Za-z0-9\+\/\=]/g;

        if (base64test.exec(input)) {
            throw new Error(`There were invalid base64 characters in the input text. Valid base64 characters are: ['A-Z', 'a-z', '0-9,' '+', '/', '=']`);
        }

        input = input.replace(/[^A-Za-z0-9\+\/\=]/g, '');

        do {
            enc1 = Base64.keyStr.indexOf(input.charAt(i++));
            enc2 = Base64.keyStr.indexOf(input.charAt(i++));
            enc3 = Base64.keyStr.indexOf(input.charAt(i++));
            enc4 = Base64.keyStr.indexOf(input.charAt(i++));
            chr1 = (enc1 << 2) | (enc2 >> 4);
            chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
            chr3 = ((enc3 & 3) << 6) | enc4;
            output = output + String.fromCharCode(chr1);

            if (enc3 != 64) output = output + String.fromCharCode(chr2);

            if (enc4 != 64) output = output + String.fromCharCode(chr3);

            chr1 = chr2 = chr3 = '';
            enc1 = enc2 = enc3 = enc4 = '';
        } while (i < input.length);

        return unescape(output);
    }
}

class MultiRegExp {
    constructor(baseRegExp) {
        const { regexp, groupIndexMapper, previousGroupsForGroup } = this._fillGroups(baseRegExp);

        this.regexp = regexp;
        this.groupIndexMapper = groupIndexMapper;
        this.previousGroupsForGroup = previousGroupsForGroup;
    }

    execForAllGroups(str, includeFullMatch) {
        let matches = RegExp.prototype.exec.call(this.regexp, str);

        if (!matches) return matches;

        let firstIndex = matches.index;
        let indexMapper = includeFullMatch
            ? Object.assign(
                  {
                      0: 0,
                  },
                  this.groupIndexMapper
              )
            : this.groupIndexMapper;
        let previousGroups = includeFullMatch
            ? Object.assign(
                  {
                      0: [],
                  },
                  this.previousGroupsForGroup
              )
            : this.previousGroupsForGroup;

        let res = Object.keys(indexMapper).map((group) => {
            let mapped = indexMapper[group];
            let match = matches[mapped];
            let start = firstIndex + previousGroups[group].reduce((sum, i) => sum + (matches[i] ? matches[i].length : 0), 0);
            let end = start + (matches[mapped] ? matches[mapped].length : 0);
            let lineColumnStart = LineColumnFinder(str).fromIndex(start);
            let lineColumnEnd = LineColumnFinder(str).fromIndex(end - 1);

            return {
                match,
                start,
                end,
                startLineNumber: lineColumnStart.line,
                startColumnNumber: lineColumnStart.col,
                endLineNumber: lineColumnEnd.line,
                endColumnNumber: lineColumnEnd.col,
            };
        });

        return res;
    }

    execForGroup(string, group) {
        const matches = RegExp.prototype.exec.call(this.regexp, string);

        if (!matches) return matches;

        const firstIndex = matches.index;

        const mapped = group == 0 ? 0 : this.groupIndexMapper[group];
        const previousGroups = group == 0 ? [] : this.previousGroupsForGroup[group];

        let r = {
            match: matches[mapped],
            start: firstIndex + previousGroups.reduce((sum, i) => sum + (matches[i] ? matches[i].length : 0), 0),
        };

        r.end = r.start + (matches[mapped] ? matches[mapped].length : 0);

        return r;
    }

    /**
     * Adds brackets before and after a part of string
     * @param str string the hole regex string
     * @param start int marks the position where ( should be inserted
     * @param end int marks the position where ) should be inserted
     * @param groupsAdded int defines the offset to the original string because of inserted brackets
     * @return {string}
     */
    _addGroupToRegexString(str, start, end, groupsAdded) {
        start += groupsAdded * 2;
        end += groupsAdded * 2;

        return str.substring(0, start) + '(' + str.substring(start, end + 1) + ')' + str.substring(end + 1);
    }

    /**
     * converts the given regex to a regex where all not captured string are going to be captured
     * it along sides generates a mapper which maps the original group index to the shifted group offset and
     * generates a list of groups indexes (including new generated capturing groups)
     * which have been closed before a given group index (unshifted)
     *
     * Example:
     * regexp: /a(?: )bc(def(ghi)xyz)/g => /(a(?: )bc)((def)(ghi)(xyz))/g
     * groupIndexMapper: {'1': 2, '2', 4}
     * previousGroupsForGroup: {'1': [1], '2': [1, 3]}
     *
     * @param regex RegExp
     * @return {{regexp: RegExp, groupIndexMapper: {}, previousGroupsForGroup: {}}}
     */
    _fillGroups(regex) {
        let regexString;
        let modifier;

        if (regex.source && regex.flags) {
            regexString = regex.source;
            modifier = regex.flags;
        } else {
            regexString = regex.toString();
            modifier = regexString.substring(regexString.lastIndexOf(regexString[0]) + 1); // sometimes order matters ;)
            regexString = regexString.substr(1, regex.toString().lastIndexOf(regexString[0]) - 1);
        }

        // regexp is greedy so it should match (? before ( right?
        // brackets may be not quoted by \
        // closing bracket may look like: ), )+, )+?, ){1,}?, ){1,1111}?
        const tester = /(\\\()|(\\\))|(\(\?)|(\()|(\)(?:\{\d+,?\d*}|[*+?])?\??)/g;

        let modifiedRegex = regexString;

        let lastGroupStartPosition = -1;
        let lastGroupEndPosition = -1;
        let lastNonGroupStartPosition = -1;
        let lastNonGroupEndPosition = -1;
        let groupsAdded = 0;
        let groupCount = 0;
        let matchArr;
        const nonGroupPositions = [];
        const groupPositions = [];
        const groupNumber = [];
        let currentLengthIndexes = [];
        const groupIndexMapper = {};
        const previousGroupsForGroup = {};

        while ((matchArr = tester.exec(regexString)) !== null) {
            if (matchArr[1] || matchArr[2]) {
                // ignore escaped brackets \(, \)
            }

            if (matchArr[3]) {
                // non capturing group (?
                let index = matchArr.index + matchArr[0].length - 1;

                lastNonGroupStartPosition = index;
                nonGroupPositions.push(index);
            } else if (matchArr[4]) {
                // capturing group (
                let index = matchArr.index + matchArr[0].length - 1;
                let lastGroupPosition = Math.max(lastGroupStartPosition, lastGroupEndPosition);

                // if a (? is found add ) before it
                if (lastNonGroupStartPosition > lastGroupPosition) {
                    // check if between ) of capturing group lies a non capturing group
                    if (lastGroupPosition < lastNonGroupEndPosition) {
                        // add groups for x1 and x2 on (?:()x1)x2(?:...
                        if (lastNonGroupEndPosition - 1 - (lastGroupPosition + 1) > 0) {
                            modifiedRegex = this._addGroupToRegexString(modifiedRegex, lastGroupPosition + 1, lastNonGroupEndPosition - 1, groupsAdded);
                            groupsAdded++;
                            lastGroupEndPosition = lastNonGroupEndPosition - 1; // imaginary position as it is not in regex but modifiedRegex
                            currentLengthIndexes.push(groupCount + groupsAdded);
                        }

                        if (lastNonGroupStartPosition - 1 - (lastNonGroupEndPosition + 1) > 0) {
                            modifiedRegex = this._addGroupToRegexString(modifiedRegex, lastNonGroupEndPosition + 1, lastNonGroupStartPosition - 2, groupsAdded);
                            groupsAdded++;
                            lastGroupEndPosition = lastNonGroupStartPosition - 1; // imaginary position as it is not in regex but modifiedRegex
                            currentLengthIndexes.push(groupCount + groupsAdded);
                        }
                    } else {
                        modifiedRegex = this._addGroupToRegexString(modifiedRegex, lastGroupPosition + 1, lastNonGroupStartPosition - 2, groupsAdded);
                        groupsAdded++;
                        lastGroupEndPosition = lastNonGroupStartPosition - 1; // imaginary position as it is not in regex but modifiedRegex
                        currentLengthIndexes.push(groupCount + groupsAdded);
                    }

                    // if necessary also add group between (? and opening bracket
                    if (index > lastNonGroupStartPosition + 2) {
                        modifiedRegex = this._addGroupToRegexString(modifiedRegex, lastNonGroupStartPosition + 2, index - 1, groupsAdded);
                        groupsAdded++;
                        lastGroupEndPosition = index - 1; // imaginary position as it is not in regex but modifiedRegex
                        currentLengthIndexes.push(groupCount + groupsAdded);
                    }
                } else if (lastGroupPosition < index - 1) {
                    modifiedRegex = this._addGroupToRegexString(modifiedRegex, lastGroupPosition + 1, index - 1, groupsAdded);
                    groupsAdded++;
                    lastGroupEndPosition = index - 1; // imaginary position as it is not in regex but modifiedRegex
                    currentLengthIndexes.push(groupCount + groupsAdded);
                }

                groupCount++;
                lastGroupStartPosition = index;
                groupPositions.push(index);
                groupNumber.push(groupCount + groupsAdded);
                groupIndexMapper[groupCount] = groupCount + groupsAdded;
                previousGroupsForGroup[groupCount] = currentLengthIndexes.slice();
            } else if (matchArr[5]) {
                // closing bracket ), )+, )+?, ){1,}?, ){1,1111}?
                let index = matchArr.index + matchArr[0].length - 1;

                if ((groupPositions.length && !nonGroupPositions.length) || groupPositions[groupPositions.length - 1] > nonGroupPositions[nonGroupPositions.length - 1]) {
                    if (lastGroupStartPosition < lastGroupEndPosition && lastGroupEndPosition < index - 1) {
                        modifiedRegex = this._addGroupToRegexString(modifiedRegex, lastGroupEndPosition + 1, index - 1, groupsAdded);
                        groupsAdded++;

                        //lastGroupEndPosition = index - 1; will be set anyway
                        currentLengthIndexes.push(groupCount + groupsAdded);
                    }

                    groupPositions.pop();
                    lastGroupEndPosition = index;

                    let toPush = groupNumber.pop();
                    currentLengthIndexes.push(toPush);
                    currentLengthIndexes = currentLengthIndexes.filter((index) => index <= toPush);
                } else if (nonGroupPositions.length) {
                    nonGroupPositions.pop();
                    lastNonGroupEndPosition = index;
                }
            }
        }

        return {
            regexp: new RegExp(modifiedRegex, modifier),
            groupIndexMapper,
            previousGroupsForGroup,
        };
    }
}

class MoveableElement {
    /**
     * Creates an instance of MoveableElement.
     * @param {HTMLElement} element
     * @param {boolean} requireKeyDown
     * @memberof MoveableElement
     */
    constructor(element, requireKeyDown) {
        this.element = element;
        this.requireKeyDown = requireKeyDown || false;
        this.handleMouseDown = this.handleMouseDown.bind(this);
        this.handleMouseUp = this.handleMouseUp.bind(this);
        this.handleMouseMove = this.handleMouseMove.bind(this);

        this.moving = false;
        this.keyPressed = false;
        this.originalCursor = getStyle(this.element, 'cursor');

        this.setupEvents();
    }

    setupEvents() {
        if (!document.body) {
            setTimeout(() => {
                this.setupEvents();
            }, 250);
        } else {
            document.body.addEventListener('keydown', (ev) => {
                if (ev.which == '17') {
                    this.keyPressed = true;
                }
            });

            document.body.addEventListener('keyup', (ev) => {
                this.keyPressed = false;
            });
        }
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {MouseEvent} ev
     * @memberof MoveableElement
     */
    handleMouseDown(ev) {
        if (this.keyPressed || !this.requireKeyDown) {
            ev.preventDefault();

            this.element.style.cursor = 'move';

            this.changePointerEvents('none');

            document.body.removeEventListener('mouseup', this.handleMouseUp);
            document.body.addEventListener('mouseup', this.handleMouseUp);

            document.body.removeEventListener('mousemove', this.handleMouseMove);
            document.body.removeEventListener('mouseleave', this.handleMouseUp);

            document.body.addEventListener('mousemove', this.handleMouseMove);
            document.body.addEventListener('mouseleave', this.handleMouseUp);

            try {
                document.querySelectorAll('iframe')[0].style.pointerEvents = 'none';
            } catch (error) {}
        }
    }

    changePointerEvents(value) {
        for (let i = 0; i < this.element.children.length; i++) {
            const child = this.element.children[i];

            child.style.pointerEvents = value;
        }
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {MouseEvent} ev
     * @memberof MoveableElement
     */
    handleMouseUp(ev) {
        this.moving = false;
        this.element.style.cursor = this.originalCursor;
        this.changePointerEvents('auto');

        document.body.removeEventListener('mouseup', this.handleMouseUp);
        document.body.removeEventListener('mousemove', this.handleMouseMove);
        document.body.removeEventListener('mouseleave', this.handleMouseUp);

        try {
            document.querySelectorAll('iframe')[0].style.pointerEvents = '';
        } catch (error) {}
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {MouseEvent} ev
     * @memberof MoveableElement
     */
    handleMouseMove(ev) {
        this.moving = true;

        let top = ev.clientY - getStyle(this.element, 'height') / 2;
        let bottom = ev.clientX - getStyle(this.element, 'width') / 2;

        this.element.style.top = `${top}px`;
        this.element.style.left = `${bottom}px`;
    }

    padCoord(coord) {
        return coord.toString().padStart(5, ' ');
    }

    init() {
        this.element.addEventListener('mousedown', this.handleMouseDown);
    }
}

class CurrentLine {
    /**
     * @typedef ILineInfo
     * @prop {string} method
     * @prop {number} line
     * @prop {string} file
     * @prop {string} filename
     */

    /**
     * Returns a single item
     *
     * @param {number} [level] Useful to return levels up on the stack. If not informed, the first (0, zero index) element of the stack will be returned
     * @returns {ILineInfo}
     */
    get(level = 0) {
        const stack = getStack();
        const i = Math.min(level + 1, stack.length - 1);
        const item = stack[i];
        const result = CurrentLine.parse(item);

        return result;
    }

    /**
     * Returns all stack
     *
     * @returns {ILineInfo[]}
     */
    all() {
        const stack = getStack();
        const result = [];

        for (let i = 1; i < stack.length; i++) {
            const item = stack[i];

            result.push(CurrentLine.parse(item));
        }

        return result;
    }

    /**
     *
     *
     * @param {NodeJS.CallSite} item
     * @returns {ILineInfo}
     */
    static parse(item) {
        const result = {
            method: item.getMethodName() || item.getFunctionName(),
            line: item.getLineNumber(),
            file: item.getFileName() || item.getScriptNameOrSourceURL(),
        };

        result.filename = result.file ? result.file.replace(/^.*\/|\\/gm, '').replace(/\.\w+$/gm, '') : null;

        return result;
    }
}

/**
 *
 *
 * @returns {NodeJS.CallSite[]}
 */
function getStack() {
    const orig = Error.prepareStackTrace;

    Error.prepareStackTrace = function (_, stack) {
        return stack;
    };

    const err = new Error();

    Error.captureStackTrace(err, arguments.callee);

    const stack = err.stack;

    Error.prepareStackTrace = orig;

    return stack;
}

class ProgressTimer {
    /**
     * Creates an instance of ProgressTimer.
     * @param {number} total
     * @memberof ProgressTimer
     */
    constructor(total) {
        this.startTime;
        this.total = total;
        this.loaded = 0;
        this.estimatedFinishDt = '';
        this.progressMessage = '';
    }

    /**
     *
     *
     * @memberof ProgressTimer
     */
    start() {
        this.startTime = new Date();
    }

    /**
     *
     *
     * @param {number} loaded
     * @param {string} msg
     * @memberof ProgressTimer
     */
    updateProgress(loaded, msg) {
        this.loaded = loaded;

        this.progress = `${((this.loaded * 100) / this.total).toFixed(2)}%`;
        this.timeRemaining = this._estimatedTimeRemaining(this.startTime, this.loaded, this.total);
        this.downloaded = `${this.loaded}/${this.total}`;
        this.completionTime = `${this._dateToISOLikeButLocal(this.estimatedFinishDt)}`;
        this.totalRuntime = `${this._ms2Timestamp(this.timeTaken)}`;

        this.updateProgressMessage(msg);
        this.printProgress();
    }

    /**
     *
     *
     * @param {string} msg
     * @memberof ProgressTimer
     */
    updateProgressMessage(msg) {
        let msgLines = [];

        msgLines.push(`      completed: ${this.progress}`);
        msgLines.push(`     downloaded: ${this.downloaded}`);
        msgLines.push(`  total runtime: ${this.totalRuntime}`);
        msgLines.push(` time remaining: ${this.timeRemaining}`);
        msgLines.push(`completion time: ${this.completionTime}`);

        if (msg) {
            msgLines.push(msg);
        }

        this.progressMessage = msgLines.join('\n');
    }

    /**
     *
     *
     * @memberof ProgressTimer
     */
    printProgress() {
        console.clear();
        console.debug(this.progressMessage);
    }

    /**
     *
     *
     * @param {Date} startTime
     * @param {number} itemsProcessed
     * @param {number} totalItems
     * @returns {string}
     * @memberof ProgressTimer
     */
    _estimatedTimeRemaining(startTime, itemsProcessed, totalItems) {
        // if (itemsProcessed == 0) {
        //     return '';
        // }

        let currentTime = new Date();
        this.timeTaken = currentTime - startTime;
        this.timeLeft = itemsProcessed == 0 ? this.timeTaken * (totalItems - itemsProcessed) : (this.timeTaken / itemsProcessed) * (totalItems - itemsProcessed);
        this.estimatedFinishDt = new Date(currentTime.getTime() + this.timeLeft);

        return this._ms2Timestamp(this.timeLeft);
    }

    /**
     *
     *
     * @param {number} ms
     * @returns {string}
     * @memberof ProgressTimer
     */
    _ms2Timestamp(ms) {
        // 1- Convert to seconds:
        let seconds = ms / 1000;

        // 2- Extract hours:
        let hours = parseInt(seconds / 3600); // 3,600 seconds in 1 hour
        seconds = seconds % 3600; // seconds remaining after extracting hours

        // 3- Extract minutes:
        let minutes = parseInt(seconds / 60); // 60 seconds in 1 minute

        // 4- Keep only seconds not extracted to minutes:
        seconds = seconds % 60;

        let parts = seconds.toString().split('.');

        seconds = parseInt(parts[0]);
        let milliseconds = parts.length > 1 ? parts[1].substring(0, 3).padEnd(3, 0) : '000';

        hours = hours.toString().padStart(2, '0');
        minutes = minutes.toString().padStart(2, '0');
        seconds = seconds.toString().padStart(2, '0');

        return `${hours}:${minutes}:${seconds}.${milliseconds}`; // hours + ':' + minutes + ':' + seconds;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {Date} date
     * @returns {string}
     * @memberof ProgressTimer
     */
    _dateToISOLikeButLocal(date) {
        let offsetMs = date.getTimezoneOffset() * 60 * 1000;
        let msLocal = date.getTime() - offsetMs;
        let dateLocal = new Date(msLocal);
        let iso = dateLocal.toISOString();
        let isoLocal = iso.slice(0, 19);

        return isoLocal.replace(/T/g, ' ');
    }
}

class Benchmark {
    constructor({ logger, printResults } = {}) {
        this.namedPerformances = {};
        this.defaultName = 'default';
        this.logger = logger;
        this.printResults = printResults == undefined ? (this.logger ? true : false) : printResults;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {string=} name
     * @memberof Benchmark
     */
    start(name) {
        name = name || this.defaultName;

        this.namedPerformances[name] = {
            startAt: this._hrtime(),
        };
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {string=} name
     * @memberof Benchmark
     */
    stop(name) {
        name = name || this.defaultName;

        const startAt = this.namedPerformances[name] && this.namedPerformances[name].startAt;

        if (!startAt) throw new Error(`Namespace: ${name} doesnt exist`);

        const diff = this._hrtime(startAt);
        const time = diff[0] * 1e3 + diff[1] * 1e-6;
        const words = this.getWords(diff);
        const preciseWords = this.getPreciseWords(diff);
        const verboseWords = this.getVerboseWords(diff);
        const verboseAbbrWords = this.getVerboseAbbrWords(diff);

        if (this.printResults) {
            let output = name != 'default' ? `[${name}] execution time:` : `execution time:`;

            this.logger(output, time); // words
        }

        return {
            name,
            time,
            words,
            preciseWords,
            verboseWords,
            verboseAbbrWords,
            diff,
        };
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {T} func
     * @param {{name: string, measure: boolean}=} { name, measure }
     * @returns {T}
     * @memberof Benchmark
     * @template T
     */
    wrapFunc(func, { name, measure = true } = {}) {
        name = this._getFuncName(func, name);

        let self = this;

        wrappedFunc.measure = measure;
        wrappedFunc.benchmark = {
            name,
            results: {
                runs: [],
                avg: null,
                min: null,
                max: null,
                total: null,
                times: null,
                runCount: 0,
            },
            reset: function () {
                this.results.runs = [];
                this.results.avg = null;
                this.results.min = null;
                this.results.max = null;
                this.results.total = null;
                this.results.times = null;
                this.results.runCount = 0;
            },
            printResults: function (logger = console.debug) {
                let output = this.name != 'default' ? `[${this.name}] execution summary:` : `execution summary:`;

                logger(output, `times: ${this.results.runCount}    total: ${self.getWords(this.results.total)}    min: ${self.getWords(this.results.min)}    max: ${self.getWords(this.results.max)}    avg: ${self.getWords(this.results.avg)}    total: ${self.getWords(this.results.total)}`);
            },
        };

        function wrappedFunc() {
            if (wrappedFunc.measure) {
                self.start(name);
            }

            let res = func(...arguments);

            if (wrappedFunc.measure) {
                wrappedFunc.benchmark.results.runCount++;

                wrappedFunc.benchmark.results.runs.push(self.stop(name));

                let times = wrappedFunc.benchmark.results.runs.map((run) => {
                    return run.time;
                });

                wrappedFunc.benchmark.results.times = times;
                wrappedFunc.benchmark.results.avg = self._getAvgTime(times);
                wrappedFunc.benchmark.results.total = self._getSumTime(times);
                wrappedFunc.benchmark.results.min = Math.min(...times);
                wrappedFunc.benchmark.results.max = Math.max(...times);
            }

            return res;
        }

        return this._defineWrappedFuncProperties(wrappedFunc, name);
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {T} func
     * @param {{name: string, measure: boolean}=} { name, measure }
     * @returns {T}
     * @memberof Benchmark
     * @template T
     */
    wrapAsyncFunc(func, { name, measure = true } = {}) {
        name = this._getFuncName(func, name);

        let self = this;

        async function wrappedFunc() {
            if (wrappedFunc.measure) self.start(name);

            let res = await func(...arguments);

            if (wrappedFunc.measure) self.stop(name);

            return res;
        }

        wrappedFunc.measure = measure;

        return this._defineWrappedFuncProperties(wrappedFunc, name);
    }

    getWords(diff) {
        return this._prettyHrtime(diff);
    }

    getPreciseWords(diff) {
        return this._prettyHrtime(diff, { precise: true });
    }

    getVerboseWords(diff) {
        return this._prettyHrtime(diff, { verbose: true });
    }

    getVerboseAbbrWords(diff) {
        return this._prettyHrtime(diff, { verbose: true, verboseAbbrv: true, precise: true });
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {number[][]} times
     * @returns {number}
     */
    _getAvgTime(times) {
        return this._getSumTime(times) / times.length;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {number[][]} times
     * @returns {number}
     */
    _getSumTime(times) {
        return times.reduce((a, b) => a + b);
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {number} ms
     * @returns {number[][]}
     * @memberof Benchmark
     */
    _ms2Hrtime(ms) {
        let seconds = Math.round(ms / 1000);
        let nanoSeconds = Math.round(ms * 1000000 - seconds * 1000000 * 1000);

        return [seconds, nanoSeconds];
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {T} func
     * @param {string=} name
     * @returns {T}
     * @memberof Benchmark
     * @template T
     */
    _getFuncName(func, name) {
        return name ? name : 'name' in func && func.name.trim() !== '' ? func.name : '[wrapped.func]';
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {Function} wrappedFunc
     * @param {string} name
     * @returns {Function}
     * @memberof Benchmark
     */
    _defineWrappedFuncProperties(wrappedFunc, name) {
        Object.defineProperty(wrappedFunc, 'name', {
            value: name,
            writable: false,
            configurable: false,
            enumerable: false,
        });

        Object.defineProperty(wrappedFunc, 'toString', {
            value: () => func.toString(),
            writable: false,
            configurable: false,
            enumerable: false,
        });

        return wrappedFunc;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {[number, number]=} time
     * @returns {[number, number]}
     * @memberof Benchmark
     */
    _hrtime(time) {
        if (typeof process !== 'undefined') return process.hrtime(time);

        var performance = typeof performance !== 'undefined' ? performance : {};
        let performanceNow = performance.now || performance.mozNow || performance.msNow || performance.oNow || performance.webkitNow || (() => new Date().getTime());

        let clocktime = performanceNow.call(performance) * 1e-3;
        let seconds = Math.floor(clocktime);
        let nanoseconds = Math.floor((clocktime % 1) * 1e9);

        if (time) {
            seconds = seconds - time[0];
            nanoseconds = nanoseconds - time[1];

            if (nanoseconds < 0) {
                seconds--;
                nanoseconds += 1e9;
            }
        }

        return [seconds, nanoseconds];
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {[number, number]=} time
     * @param {{verbose: boolean; verboseAbbrv: boolean; precise: boolean}} { verbose = false, verboseAbbrv = false, precise = false }
     * @returns {string}
     * @memberof Benchmark
     */
    _prettyHrtime(time, { verbose = false, verboseAbbrv = false, precise = false } = {}) {
        let i, spot, sourceAtStep, valAtStep, decimals, strAtStep, results, totalSeconds;

        let minimalDesc = ['h', 'min', 's', 'ms', 'μs', 'ns'];
        let verboseDesc = !verboseAbbrv ? ['hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond'] : minimalDesc;
        let convert = [60 * 60, 60, 1, 1e6, 1e3, 1];

        if (typeof time === 'number') {
            time = this._ms2Hrtime(time);
        }

        if (!Array.isArray(time) || time.length !== 2) return '';

        if (typeof time[0] !== 'number' || typeof time[1] !== 'number') return '';

        // normalize source array due to changes in node v5.4+
        if (time[1] < 0) {
            totalSeconds = time[0] + time[1] / 1e9;
            time[0] = parseInt(totalSeconds);
            time[1] = parseFloat((totalSeconds % 1).toPrecision(9)) * 1e9;
        }

        results = '';

        for (i = 0; i < 6; i++) {
            // grabbing first or second spot in source array
            spot = i < 3 ? 0 : 1;
            sourceAtStep = time[spot];

            if (i !== 3 && i !== 0) {
                // trim off previous portions
                sourceAtStep = sourceAtStep % convert[i - 1];
            }

            if (i === 2) {
                // get partial seconds from other portion of the array
                sourceAtStep += time[1] / 1e9;
            }

            // val at this unit
            valAtStep = sourceAtStep / convert[i];

            if (valAtStep >= 1) {
                if (verbose) {
                    // deal in whole units, subsequent laps will get the decimal portion
                    valAtStep = Math.floor(valAtStep);
                }

                if (!precise) {
                    // don't fling too many decimals
                    decimals = valAtStep >= 10 ? 0 : 2;
                    strAtStep = valAtStep.toFixed(decimals);
                } else {
                    strAtStep = valAtStep.toString();
                }

                if (strAtStep.indexOf('.') > -1 && strAtStep[strAtStep.length - 1] === '0') {
                    // remove trailing zeros
                    strAtStep = strAtStep.replace(/\.?0+$/, '');
                }

                if (results) {
                    // append space if we have a previous value
                    results += ' ';
                }

                // append the value
                results += strAtStep;

                // append units
                if (verbose) {
                    results += verboseAbbrv ? `${verboseDesc[i]}` : ` ${verboseDesc[i]}`;

                    if (!verboseAbbrv && strAtStep !== '1') {
                        results += 's';
                    }
                } else {
                    results += ` ${minimalDesc[i]}`;
                }

                if (!verbose) {
                    // verbose gets as many groups as necessary, the rest get only one
                    break;
                }
            }
        }

        return results;
    }
}

class ArrayStat {
    /**
     * Creates an instance of ArrayStat.
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {number[]} array
     * @memberof ArrayStat
     */
    constructor(array) {
        this.array = array;
    }

    _getCloned() {
        return this.array.slice(0);
    }

    min() {
        return Math.min.apply(null, this.array);
    }

    max() {
        return Math.max.apply(null, this.array);
    }

    range() {
        return this.max(this.array) - this.min(this.array);
    }

    midrange() {
        return this.range(this.array) / 2;
    }

    sum(array) {
        array = array || this.array;

        let total = 0;

        for (let i = 0, l = array.length; i < l; i++) total += array[i];

        return total;
    }

    mean(array) {
        array = array || this.array;

        return this.sum(array) / array.length;
    }

    median() {
        let array = this._getCloned();

        array.sort(function (a, b) {
            return a - b;
        });

        let mid = array.length / 2;

        return mid % 1 ? array[mid - 0.5] : (array[mid - 1] + array[mid]) / 2;
    }

    modes() {
        if (!this.array.length) return [];

        let modeMap = {};
        let maxCount = 0;
        let modes = [];

        this.array.forEach(function (val) {
            if (!modeMap[val]) modeMap[val] = 1;
            else modeMap[val]++;

            if (modeMap[val] > maxCount) {
                modes = [val];
                maxCount = modeMap[val];
            } else if (modeMap[val] === maxCount) {
                modes.push(val);

                maxCount = modeMap[val];
            }
        });

        return modes;
    }

    letiance() {
        let mean = this.mean();

        return this.mean(
            this._getCloned().map(function (num) {
                return Math.pow(num - mean, 2);
            })
        );
    }

    standardDeviation() {
        return Math.sqrt(this.letiance());
    }

    meanAbsoluteDeviation() {
        let mean = this.mean();

        return this.mean(
            this._getCloned().map(function (num) {
                return Math.abs(num - mean);
            })
        );
    }

    zScores() {
        let mean = this.mean();
        let standardDeviation = this.standardDeviation();

        return this._getCloned().map(function (num) {
            return (num - mean) / standardDeviation;
        });
    }

    withinStd(val, stdev) {
        let low = this.mean() - stdev * this.standardDeviation(); // x.deviation;
        let hi = this.mean() + stdev * this.standardDeviation(); // x.deviation;
        let res = val > low && val < hi;

        console.log(`val: ${val.toString().padEnd(5, ' ')}    mean: ${this.mean()}    stdev: ${this.standardDeviation()}    hi: ${hi}    low: ${low}    res: ${res}`);

        return res;
    }
}

memoizeClass(ArrayStat);

let LineColumnFinder = (function LineColumnFinder() {
    let isArray = Array.isArray;
    let isObject = (val) => val != null && typeof val === 'object' && Array.isArray(val) === false;
    let slice = Array.prototype.slice;

    /**
     * Finder for index and line-column from given string.
     *
     * You can call this without `new` operator as it returns an instance anyway.
     *
     * @class
     * @param {string} str - A string to be parsed.
     * @param {Object|number} [options] - Options.
     *     This can be an index in the string for shorthand of `lineColumn(str, index)`.
     * @param {number} [options.origin=1] - The origin value of line and column.
     */
    function LineColumnFinder(str, options) {
        if (!(this instanceof LineColumnFinder)) {
            if (typeof options === 'number') {
                return new LineColumnFinder(str).fromIndex(options);
            }

            return new LineColumnFinder(str, options);
        }

        this.str = str || '';
        this.lineToIndex = buildLineToIndex(this.str);

        options = options || {};

        this.origin = typeof options.origin === 'undefined' ? 1 : options.origin;
    }

    /**
     * Find line and column from index in the string.
     *
     * @param  {number} index - Index in the string. (0-origin)
     * @return {Object|null}
     *     Found line number and column number in object `{ line: X, col: Y }`.
     *     If the given index is out of range, it returns `null`.
     */
    LineColumnFinder.prototype.fromIndex = function (index) {
        if (index < 0 || index >= this.str.length || isNaN(index)) {
            return null;
        }

        let line = findLowerIndexInRangeArray(index, this.lineToIndex);

        return {
            line: line + this.origin,
            col: index - this.lineToIndex[line] + this.origin,
        };
    };

    /**
     * Find index from line and column in the string.
     *
     * @param  {number|Object|Array} line - Line number in the string.
     *     This can be an Object of `{ line: X, col: Y }`, or
     *     an Array of `[line, col]`.
     * @param  {number} [column] - Column number in the string.
     *     This must be omitted or undefined when Object or Array is given
     *     to the first argument.
     * @return {number}
     *     Found index in the string. (always 0-origin)
     *     If the given line or column is out of range, it returns `-1`.
     */
    LineColumnFinder.prototype.toIndex = function (line, column) {
        if (typeof column === 'undefined') {
            if (isArray(line) && line.length >= 2) {
                return this.toIndex(line[0], line[1]);
            }

            if (isObject(line) && 'line' in line && ('col' in line || 'column' in line)) {
                return this.toIndex(line.line, 'col' in line ? line.col : line.column);
            }

            return -1;
        }

        if (isNaN(line) || isNaN(column)) {
            return -1;
        }

        line -= this.origin;
        column -= this.origin;

        if (line >= 0 && column >= 0 && line < this.lineToIndex.length) {
            let lineIndex = this.lineToIndex[line];
            let nextIndex = line === this.lineToIndex.length - 1 ? this.str.length : this.lineToIndex[line + 1];

            if (column < nextIndex - lineIndex) {
                return lineIndex + column;
            }
        }

        return -1;
    };

    /**
     * Build an array of indexes of each line from a string.
     *
     * @private
     * @param   str {string}  An input string.
     * @return  {number[]}    Built array of indexes. The key is line number.
     */
    function buildLineToIndex(str) {
        let lines = str.split('\n');
        let lineToIndex = new Array(lines.length);
        let index = 0;

        for (let i = 0, l = lines.length; i < l; i++) {
            lineToIndex[i] = index;
            index += lines[i].length + /* "\n".length */ 1;
        }

        return lineToIndex;
    }

    /**
     * Find a lower-bound index of a value in a sorted array of ranges.
     *
     * Assume `arr = [0, 5, 10, 15, 20]` and
     * this returns `1` for `value = 7` (5 <= value < 10),
     * and returns `3` for `value = 18` (15 <= value < 20).
     *
     * @private
     * @param  arr   {number[]} An array of values representing ranges.
     * @param  value {number}   A value to be searched.
     * @return {number} Found index. If not found `-1`.
     */
    function findLowerIndexInRangeArray(value, arr) {
        if (value >= arr[arr.length - 1]) {
            return arr.length - 1;
        }

        let min = 0,
            max = arr.length - 2,
            mid;

        while (min < max) {
            mid = min + ((max - min) >> 1);

            if (value < arr[mid]) {
                max = mid - 1;
            } else if (value >= arr[mid + 1]) {
                min = mid + 1;
            } else {
                // value >= arr[mid] && value < arr[mid + 1]
                min = mid;
                break;
            }
        }

        return min;
    }

    return LineColumnFinder;
})();

class CustomContextMenu {
    /**
     * Example menuItems
     *
     * ```javascript
     * let menuItems = [
     *    {
     *        type: 'item',
     *        label: 'Test1',
     *        onClick: () => {
     *            alert('test1');
     *        },
     *    },
     *    {
     *        type: 'item',
     *        label: 'Test2',
     *        onClick: () => {
     *            console.debug('test2');
     *        },
     *    },
     *    {
     *        type: 'break',
     *    },
     *    {
     *        type: 'item',
     *        label: 'Test3',
     *        onClick: () => {
     *            console.debug('test3');
     *        },
     *    },
     * ];
     *   ```
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {HTMLElement} elemToAttachTo
     * @param {*} menuItems
     * @memberof CustomContextMenu
     */
    constructor(elemToAttachTo, menuItems, onContextMenu) {
        this.elem = elemToAttachTo;
        this.menuItems = menuItems;
        this.menu = null;
        this.onContextMenu = onContextMenu;

        this._createMenu();
        this._setupEvents();

        this.hide = debounce(this.hide.bind(this), 500, true);
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {number} top
     * @param {number} left
     * @memberof CustomContextMenu
     */
    show(top, left) {
        document.body.appendChild(this.menu);

        this.menu.style.display = 'block';

        this.menu.style.top = `${top}px`;
        this.menu.style.left = `${left}px`;

        this.menu.setAttribute('tabindex', '0');
        this.menu.focus();
    }

    hide() {
        this.menu.style.display = 'none';

        if (document.body.contains(this.menu)) {
            this.menu.remove();
        }
    }

    _setupEvents() {
        this.elem.addEventListener('contextmenu', (ev) => {
            ev.preventDefault();

            if (this.onContextMenu) {
                this.onContextMenu(ev);
            }

            this.show(ev.pageY, ev.pageX);
        });

        document.addEventListener('click', (ev) => {
            if (document.body.contains(this.menu) && !this._isHover(this.menu)) {
                this.hide();
            }
        });

        window.addEventListener('blur', (ev) => {
            this.hide();
        });

        this.menu.addEventListener('blur', (ev) => {
            this.hide();
        });
    }

    _createMenu() {
        this.menu = this._createMenuContainer();

        for (let i = 0; i < this.menuItems.length; i++) {
            let itemConfig = this.menuItems[i];

            switch (itemConfig.type) {
                case 'item':
                    this.menu.appendChild(this._createItem(itemConfig));

                    break;

                case 'break':
                    this.menu.appendChild(this._createBreak());

                    break;

                default:
                    break;
            }
        }

        // document.body.appendChild(this.menu);
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @returns {HTMLElement}
     * @memberof CustomContextMenu
     */
    _createMenuContainer() {
        let html = `<div class="context" hidden></div>`;

        let elem = this._createElementsFromHTML(html);

        return elem;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {*} itemConfig
     * @returns {HTMLElement}
     * @memberof CustomContextMenu
     */
    _createItem(itemConfig) {
        let html = `<div class="context_item">
    <div class="inner_item">
        ${itemConfig.label}
    </div>
</div>`;

        let elem = this._createElementsFromHTML(html);

        if (itemConfig.id) {
            elem.id = itemConfig.id;
        }

        if (itemConfig.onClick) {
            elem.addEventListener('click', (ev) => {
                itemConfig.onClick(ev);

                this.hide();
            });
        }

        return elem;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @returns {HTMLElement}
     * @memberof CustomContextMenu
     */
    _createBreak() {
        let html = `<div class="context_hr"></div>`;

        let elem = this._createElementsFromHTML(html);

        return elem;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {string} htmlStr
     * @returns {HTMLElement}
     */
    _createElementsFromHTML(htmlStr) {
        let div = document.createElement('div');

        div.innerHTML = htmlStr.trim();

        return div.firstChild;
    }

    _isHover(elem) {
        return elem.parentElement.querySelector(':hover') === elem;
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @class LocalStorageEx
 */
class LocalStorageEx {
    /**
     * Creates an instance of LocalStorageEx.
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @memberof LocalStorageEx
     */
    constructor() {
        this.__storage = localStorage;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @readonly
     * @memberof LocalStorageEx
     */
    get UNDEFINED_SAVED_VALUE() {
        return '__** undefined **__';
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @readonly
     * @memberof LocalStorageEx
     */
    get size() {
        let total = 0;

        for (let x in this.__storage) {
            // Value is multiplied by 2 due to data being stored in `utf-16` format, which requires twice the space.
            let amount = this.__storage[x].length * 2;

            if (!isNaN(amount) && this.__storage.hasOwnProperty(x)) {
                total += amount;
            }
        }

        return total;
    }

    /**
     * Determine if browser supports local storage.
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @returns {boolean}
     * @memberof LocalStorageEx
     */
    isSupported() {
        return typeof Storage !== 'undefined';
    }

    /**
     * Check if key exists in local storage.
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {*} key
     * @returns {boolean}
     * @memberof LocalStorageEx
     */
    has(key) {
        if (typeof key === 'object') {
            key = JSON.stringify(key);
        }

        return this.__storage.hasOwnProperty(key);
    }

    /**
     * Retrieve an object from local storage.
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {*} key
     * @param {*} [defaultValue=null]
     * @returns {*}
     * @memberof LocalStorageEx
     */
    get(key, defaultValue = null) {
        if (typeof key === 'object') {
            key = JSON.stringify(key);
        }

        if (!this.has(key)) {
            return defaultValue;
        }

        let item = this.__storage.getItem(key);

        try {
            if (item === '__** undefined **__') {
                return undefined;
            } else {
                return JSON.parse(item);
            }
        } catch (error) {
            return item;
        }
    }

    /**
     * Save some value to local storage.
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {string} key
     * @param {*} value
     * @returns {void}
     * @memberof LocalStorageEx
     */
    set(key, value) {
        if (typeof key === 'object') {
            key = JSON.stringify(key);
        }

        if (value === undefined) {
            value = this.UNDEFINED_SAVED_VALUE;
        } else if (typeof value === 'object') {
            value = JSON.stringify(value);
        }

        this.__storage.setItem(key, value);
    }

    /**
     * Remove element from local storage.
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {*} key
     * @returns {void}
     * @memberof LocalStorageEx
     */
    remove(key) {
        if (typeof key === 'object') {
            key = JSON.stringify(key);
        }

        this.__storage.removeItem(key);
    }

    toString() {
        return JSON.parse(JSON.stringify(this.__storage));
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @class SessionStorageEx
 * @extends {LocalStorageEx}
 */
class SessionStorageEx extends LocalStorageEx {
    /**
     * Creates an instance of SessionStorageEx.
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @memberof SessionStorageEx
     */
    constructor() {
        super();

        this.__storage = sessionStorage;
    }
}

class IgnoreCaseMap extends Map {
    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {string} key
     * @param {*} value
     * @returns {this}
     * @memberof IgnoreCaseMap
     */
    set(key, value) {
        return super.set(key.toLocaleLowerCase(), value);
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {string} key
     * @returns {*}
     * @memberof IgnoreCaseMap
     */
    get(key) {
        return super.get(key.toLocaleLowerCase());
    }
}

// #endregion Helper Classes

// #region Helper Functions

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {string} name
 * @param {{logLevel: log.LogLevelDesc, tag: string}} logLevel
 * @return {log.Logger}
 */
function getLogger(name, { logLevel, tag }) {
    prefix.reg(log);

    const colors = {
        TRACE: '220;86;220',
        DEBUG: '86;86;220',
        INFO: '134;134;221',
        WARN: '220;220;86',
        ERROR: '220;86;86',
    };

    /** @type {prefix.LoglevelPluginPrefixOptions} */
    let options = {
        // template: tag ? `[%t] %l [${tag}] %n:` : '[%t] %l %n:',
        levelFormatter: function (level) {
            return level.toUpperCase();
        },
        nameFormatter: function (name) {
            return name || 'root';
        },
        timestampFormatter: function (date) {
            return date.toTimeString().replace(/.*(\d{2}:\d{2}:\d{2}).*/, '$1');
        },
        format: function (level, name, timestamp) {
            let _timestamp = `\x1B[90m[${timestamp}]\x1B[m`;
            let _level = `\x1B[38;2;${colors[level.toUpperCase()]}m${level.toUpperCase()}\x1B[m`;
            let _name = `\x1B[38;2;38;177;38m${tag ? `[${tag}-` : '['}${name}]\x1B[m`;

            let _format = `${_timestamp} ${_level} ${_name}:`;

            return _format;
        },
    };

    const logger = log.getLogger(name);

    prefix.apply(logger, options);

    logger.setLevel(logLevel || 'WARN');

    return logger;
}

function pp(obj, fn) {
    fn = fn || console.log;

    fn(pformat(obj));
}

function pformat(obj, space = 4) {
    return JSON.stringify(obj, null, space);
}

function removeAllButLastStrPattern(string, token) {
    let parts = string.split(token);

    if (parts[1] === undefined) return string;
    else return parts.slice(0, -1).join('') + token + parts.slice(-1);
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {Array.<T> | Array} arr
 * @param {?function(T, T): boolean} callbackObjs
 * @return {T[]}
 * @template T
 */
function dedupeArr(arr, callbackObjs) {
    if (callbackObjs) {
        let tempArr = /** @type {[]} */ (arr).filter((value, index) => {
            return (
                index ===
                arr.findIndex((other) => {
                    return callbackObjs(value, other);
                })
            );
        });

        return tempArr;
    } else {
        return [...new Set(arr)];
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {any} obj
 * @returns {boolean}
 */
function isClass(obj) {
    return typeof obj === 'function' && /^\s*class\s+/.test(obj.toString());
}

/**
 * Checks whether a variable is a class or an instance created with `new`.
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {*} value The variable to check.
 * @returns {boolean} `true` if the variable is a class or an instance created with `new`, `false` otherwise.
 */
function isClassOrInstance(value) {
    // prettier-ignore
    if (typeof value === 'function' &&
        value.prototype &&
        typeof value.prototype.constructor === 'function' &&
        value.prototype.constructor !== Array &&
        value.prototype.constructor !== Object) {
        return true; // It's a class
    } else if (typeof value === 'object' &&
               value.constructor &&
               typeof value.constructor === 'function' &&
               value.constructor.prototype &&
               typeof value.constructor.prototype.constructor === 'function' &&
               value.constructor !== Array &&
               value.constructor !== Object) {
        return true; // It's an instance created with new
    }

    return false;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {*} value The variable to check.
 * @returns {boolean}
 */
function isFunction(value) {
    try {
        return typeof value == 'function';
    } catch (error) {
        return false;
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {object} obj
 * @param {{ propsToExclude?: string[]; namesOnly: boolean; removeDuplicates: boolean; asObject: boolean }} [{ propsToExclude = [], namesOnly = false, removeDuplicates = true, asObject = false } = {}]
 * @returns
 */
function getObjProps(obj, { propsToExclude = [], namesOnly = false, removeDuplicates = true, asObject = false } = {}) {
    // Default
    let _propsToExclude = [
        //
        '__defineGetter__',
        '__defineSetter__',
        '__lookupSetter__',
        '__lookupGetter__',
        '__proto__',
        '__original__',

        'caller',
        'callee',
        'arguments',

        'toString',
        'valueOf',
        'constructor',
        'hasOwnProperty',
        'isPrototypeOf',
        'propertyIsEnumerable',
        'toLocaleString',
    ];

    _propsToExclude = propsToExclude && Array.isArray(propsToExclude) ? _propsToExclude.concat(propsToExclude) : _propsToExclude;

    let objHierarchy = getObjHierarchy(obj);
    let propNames = getPropNames(objHierarchy);
    let plainObj = {};
    let objKeys = [];

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {any} obj
     * @returns {Array<any>}
     */
    function getObjHierarchy(obj) {
        let objs = [obj];

        obj = isClassOrInstance(obj) ? obj.prototype || obj.__proto__ : obj;

        do {
            objs.push(obj);
        } while ((obj = Object.getPrototypeOf(obj)));

        return objs;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {Array<any>} objHierarchy
     * @returns {string[]}
     */
    function getPropNames(objHierarchy) {
        /** @type {string[]} */
        let propNames = [];

        for (let i = 0; i < objHierarchy.length; i++) {
            const _obj = objHierarchy[i];

            let getPropFuncs = [Object.getOwnPropertyNames, Object.getOwnPropertySymbols];

            getPropFuncs.forEach((func) => {
                let _propNames = func(_obj);

                _propNames.forEach((propName) => {
                    if (!_propsToExclude.includes(propName) && !propNames.includes(propName)) {
                        propNames.push(propName);
                    }
                });
            });
        }

        return propNames;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {{ name: string, value: any }[]} props
     * @return {{ name: string, value: any }[]}
     */
    function dedupeProps(props) {
        function findNonNullProp(props, name) {
            let res = props.find((prop) => prop.name == name && prop.value != null);

            if (!res) {
                res = props.find((prop) => prop.name == name);
            }

            return res;
        }

        function propsContains(props, name) {
            return props.some((prop) => prop.name == name);
        }

        let newProps = [];

        for (let i = 0; i < props.length; i++) {
            const prop = props[i];

            let tempProp = findNonNullProp(props, prop.name);

            if (!propsContains(newProps, tempProp.name)) {
                newProps.push(tempProp);
            }
        }

        return newProps;
    }

    function getProps(objHierarchy, doFuncs = false) {
        /** @type {{ name: string, value: any }} */
        let props = [];

        for (let o = 0; o < objHierarchy.length; o++) {
            const _obj = objHierarchy[o];

            for (let p = 0; p < propNames.length; p++) {
                const propName = propNames[p];
                let value;

                try {
                    value = _obj[propName];
                } catch (error) {}

                if (!_propsToExclude.includes(propName)) {
                    if (asObject) {
                        if (!objKeys.includes(propName)) {
                            objKeys.push(propName);

                            plainObj[propName] = value;
                        }
                    } else {
                        props.push({
                            name: propName,
                            value: value,
                        });
                    }
                }
            }
        }

        if (!asObject) {
            if (removeDuplicates) {
                props = dedupeProps(props);
            }

            props = props.filter(function (prop, i, props) {
                let exprs = [
                    //
                    !_propsToExclude.includes(prop.name),
                    // props[i + 1] && prop.name != props[i + 1].name,
                    ...(doFuncs ? [isFunction(prop.value)] : [!isFunction(prop.value)]),
                ];

                return exprs.every(Boolean);
            });
        }

        if (asObject) {
            return plainObj;
        } else {
            return props.sort(function (a, b) {
                let aName = typeof a.name == 'symbol' ? a.name.toString() : a.name;
                let bName = typeof b.name == 'symbol' ? b.name.toString() : b.name;

                if (aName < bName) return -1;
                if (aName > bName) return 1;

                return 0;
            });
        }
    }

    let res;

    if (asObject) {
        getProps(objHierarchy, true);
        getProps(objHierarchy);

        res = plainObj;
    } else {
        res = {
            funcs: getProps(objHierarchy, true),
            props: getProps(objHierarchy),
        };

        if (namesOnly) {
            res.funcs = res.funcs.filter((func) => func.name.toString() != 'Symbol(Symbol.hasInstance)').map((func) => func.name);
            res.props = res.props.filter((prop) => prop.name.toString() != 'Symbol(Symbol.hasInstance)').map((prop) => prop.name);
        }
    }

    objHierarchy = null;

    return res;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {Window} [_window=window]
 * @param {{ namesOnly: boolean; asObject: boolean }} [{ namesOnly = false, asObject = false } = {}]
 * @returns
 */
function getUserDefinedGlobalProps(_window = null, { namesOnly = false, asObject = false } = {}) {
    _window = _window || getWindow();

    let iframe = document.createElement('iframe');

    iframe.style.display = 'none';

    document.body.appendChild(iframe);

    let plainObj = {};
    let objKeys = [];

    function getProps(obj, doFuncs = false) {
        let props = [];
        let _obj = obj;

        let getPropFuncs = [Object.getOwnPropertyNames, Object.getOwnPropertySymbols];

        getPropFuncs.forEach((func) => {
            let propNames = func(_obj);

            for (let i = 0; i < propNames.length; i++) {
                const propName = propNames[i];
                let value;

                try {
                    value = _obj[propName];
                } catch (error) {}

                if (isNumber(propName) && value?.constructor?.name == 'Window') continue;

                if (!iframe.contentWindow.hasOwnProperty(propName)) {
                    if (asObject) {
                        if (!objKeys.includes(propName)) {
                            objKeys.push(propName);

                            plainObj[propName] = value;
                        }
                    } else {
                        props.push({
                            name: propName,
                            value: value,
                        });
                    }
                }
            }
        });

        if (!asObject) {
            props = props.filter(function (prop, i, props) {
                let propName1 = prop.name;
                let propName2 = props[i + 1] ? props[i + 1].name : undefined;
                let propValue1 = prop.value;
                let propValue2 = props[i + 1] ? props[i + 1].value : undefined;

                let exprs = [
                    //
                    // props[i + 1] && propName1 != propName2,
                    (props[i + 1] && propName1.constructor.name == 'Symbol' && propName2.constructor.name == 'Symbol' && propValue1 != propValue2) || propName1 != propName2,
                    ...(doFuncs ? [isFunction(obj[propName1])] : [!isFunction(obj[propName1])]),
                ];

                return exprs.every(Boolean);
            });
        }

        if (asObject) {
            return plainObj;
        } else {
            return props.sort(function (a, b) {
                let aName = typeof a.name == 'symbol' ? a.name.toString() : a.name;
                let bName = typeof b.name == 'symbol' ? b.name.toString() : b.name;

                if (aName < bName) return -1;
                if (aName > bName) return 1;

                return 0;
            });
        }
    }

    let res;

    if (asObject) {
        getProps(_window, true);
        getProps(_window);

        res = plainObj;
    } else {
        res = {
            funcs: getProps(_window, true),
            props: getProps(_window),
        };

        if (namesOnly) {
            res.funcs = res.funcs.filter((func) => func.name.toString() != 'Symbol(Symbol.hasInstance)').map((func) => func.name);
            res.props = res.props.filter((prop) => prop.name.toString() != 'Symbol(Symbol.hasInstance)').map((prop) => prop.name);
        }
    }

    document.body.removeChild(iframe);

    return res;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {T} obj
 * @param {T | boolean} thisArg
 * @returns {T}
 * @template T
 */
function storeObjOriginalFuncs(obj, thisArg = true) {
    let props = getObjProps(obj);

    obj.__original__ = {};

    for (let i = 0; i < props.funcs.length; i++) {
        const func = props.funcs[i];

        if (thisArg == true) {
            obj.__original__[func.name] = func.value.bind(obj);
        } else {
            obj.__original__[func.name] = thisArg != false && thisArg != null && thisArg != undefined ? func.value.bind(thisArg) : func.value;
        }
    }

    return obj;
}

function printProps(obj, title) {
    let headerFooterBanner = '*********************************************************';

    console.log(headerFooterBanner);
    console.log(`* ${title || ''}`);
    console.log(headerFooterBanner);

    for (let key in obj) console.log(key + ': ', [obj[key]]);

    console.log(headerFooterBanner);
}

function sortObject(o, desc) {
    let sorted = {};
    let key;
    let a = [];

    for (key in o) {
        if (o.hasOwnProperty(key)) a.push(key);
    }

    if (desc) a.sort(sortDescending);
    else a.sort(sortAscending);

    for (key = 0; key < a.length; key++) sorted[a[key]] = o[a[key]];

    return sorted;
}

function sortAscending(a, b) {
    if (typeof a == 'string') {
        a = a.toLowerCase();
        b = b.toLowerCase();
    }

    if (a < b) return -1;
    else if (a > b) return 1;
    else return 0;
}

function sortDescending(a, b) {
    if (typeof a == 'string') {
        a = a.toLowerCase();
        b = b.toLowerCase();
    }

    if (a > b) return -1;
    else if (a < b) return 1;
    else return 0;
}

function getFileExtension(sFile) {
    return sFile.replace(/^(.*)(\.[^/.]+)$/, '$2');
}

/**
 * Async wait function.
 * Example:
 * (async () => {
 *     await wait(4000).then(() => {
 *         console.log(new Date().toLocaleTimeString());
 *     }).then(() => {
 *         console.log('here');
 *     });
 * })();
 *
 * @param {number} ms - Milliseconds to wait.
 * @param {boolean} [synchronous=false] - Wait synchronously.
 */
async function wait(ms, synchronous = false) {
    let _wait = (ms, synchronous) => {
        if (synchronous) {
            let start = Date.now();
            let now = start;

            while (now - start < ms) now = Date.now();
        } else {
            return new Promise((resolve) => setTimeout(resolve, ms));
        }
    };

    await _wait(ms, synchronous);
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {() => bool} condition
 * @param {{ timeout?: number; callback: () => T; conditionIsAsync: boolean; }} [{ timeout, callback, conditionIsAsync = false } = {}]
 * @returns {T}
 * @template T
 */
async function waitUntil(condition, { timeout, callback, conditionIsAsync = false } = {}) {
    timeout = timeout || -1;
    let maxTime = timeout == -1 ? 20000 : -1;
    let startTime = new Date();

    let timeRanOut = false;

    let done = (() => {
        let deferred = {};

        deferred.promise = new Promise((resolve, reject) => {
            deferred.resolve = resolve;
            deferred.reject = reject;
        });

        return deferred;
    })();

    /** @type {number} */
    let timeoutId;

    if (timeout && timeout > 0) {
        timeoutId = setTimeout(() => {
            timeRanOut = true;

            return done.reject();
        }, timeout);
    }

    let loop = async () => {
        let endTime = new Date();
        let elapsed = endTime - startTime;

        let conditionResult = conditionIsAsync ? await condition() : condition();

        if (conditionResult || timeRanOut || (maxTime != -1 && elapsed > maxTime)) {
            clearTimeout(timeoutId);

            return done.resolve(callback ? await callback() : undefined);
        }

        setTimeout(loop, 0);
    };

    setTimeout(loop, 0);

    return done.promise;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {any} obj
 * @param {boolean} [getInherited=false]
 * @returns {string}
 */
function getType(obj, getInherited = false) {
    let _typeVar = (function (global) {
        let cache = {};

        return function (obj) {
            let key;

            // null
            if (obj == null) return 'null';

            // window/global
            if (obj == global) return 'global';

            // basic: string, boolean, number, undefined
            if (!['object', 'function'].includes((key = typeof obj))) return key;

            if (obj.constructor != undefined && obj.constructor.name != 'Object' && !getInherited) return obj.constructor.name;

            // cached. date, regexp, error, object, array, math
            // and get XXXX from [object XXXX], and cache it
            return cache[(key = {}.toString.call(obj))] || (cache[key] = key.slice(8, -1));
        };
    })(globalThis);

    return _typeVar(obj);
}

/**
 * Returns a function, that, as long as it continues to be invoked, will not
 * be triggered. The function will be called after it stops being called for
 * N milliseconds. If `immediate` is passed, trigger the function on the
 * leading edge, instead of the trailing.
 *
 * @param {function} func
 * @param {Number} wait
 * @param {Boolean} immediate
 * @returns
 */
function debounce(func, wait, immediate) {
    let timeout;

    return function () {
        let context = this,
            args = arguments;

        let later = function () {
            timeout = null;

            if (!immediate) func.apply(context, args);
        };

        let callNow = immediate && !timeout;

        clearTimeout(timeout);
        timeout = setTimeout(later, wait);

        if (callNow) func.apply(context, args);
    };
}

function equals(x, y) {
    if (x === y) return true;
    // if both x and y are null or undefined and exactly the same

    if (!(x instanceof Object) || !(y instanceof Object)) return false;
    // if they are not strictly equal, they both need to be Objects

    if (x.constructor !== y.constructor) return false;
    // they must have the exact same prototype chain, the closest we can do is
    // test there constructor.

    for (let p in x) {
        if (!x.hasOwnProperty(p)) continue;
        // other properties were tested using x.constructor === y.constructor

        if (!y.hasOwnProperty(p)) return false;
        // allows to compare x[ p ] and y[ p ] when set to undefined

        if (x[p] === y[p]) continue;
        // if they have the same strict value or identity then they are equal

        if (typeof x[p] !== 'object') return false;
        // Numbers, Strings, Functions, Booleans must be strictly equal

        if (!equals(x[p], y[p])) return false;
        // Objects and Arrays must be tested recursively
    }

    for (p in y) {
        if (y.hasOwnProperty(p) && !x.hasOwnProperty(p)) return false;
        // allows x[ p ] to be set to undefined
    }
    return true;
}

function isEncoded(uri) {
    uri = uri || '';

    return uri !== decodeURIComponent(uri);
}

function fullyDecodeURI(uri) {
    while (isEncoded(uri)) uri = decodeURIComponent(uri);

    return uri;
}

/**
 * Get difference in days between two dates.
 *
 * @param {Date} a
 * @param {Date} b
 * @returns
 */
function dateDiffInDays(a, b) {
    let _MS_PER_DAY = 1000 * 60 * 60 * 24;

    // Discard the time and time-zone information.
    let utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
    let utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());

    return Math.floor((utc1 - utc2) / _MS_PER_DAY);
}

function randomInt(min, max) {
    return Math.floor(Math.random() * (max - min + 1) + min);
}

function randomFloat(min, max) {
    return Math.random() * (max - min + 1) + min;
}

function keySort(keys, desc) {
    return function (a, b) {
        let aVal = null;
        let bVal = null;

        for (let i = 0; i < keys.length; i++) {
            const key = keys[i];

            if (i == 0) {
                aVal = a[key];
                bVal = b[key];
            } else {
                aVal = aVal[key];
                bVal = bVal[key];
            }
        }
        return desc ? ~~(aVal < bVal) : ~~(aVal > bVal);
    };
}

function observe(obj, handler) {
    return new Proxy(obj, {
        get(target, key) {
            return target[key];
        },
        set(target, key, value) {
            target[key] = value;

            if (handler) handler();
        },
    });
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {Console} console
 */
function addSaveToConsole(console) {
    console.save = function (data, filename) {
        if (!data) {
            console.error('Console.save: No data');

            return;
        }

        if (!filename) filename = 'console.json';

        if (typeof data === 'object') data = JSON.stringify(data, undefined, 4);

        let blob = new Blob([data], {
            type: 'text/json',
        });
        let event = document.createEvent('MouseEvents');
        let tempElem = document.createElement('a');

        tempElem.download = filename;
        tempElem.href = window.URL.createObjectURL(blob);
        tempElem.dataset.downloadurl = ['text/json', tempElem.download, tempElem.href].join(':');

        event.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
        tempElem.dispatchEvent(event);
    };
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {Window} _window
 * @param {string} propName
 * @param {{} | [] | any} value
 */
function setupWindowProps(_window, propName, value) {
    if (getType(value) == 'object') {
        if (typeof _window[propName] === 'undefined' || _window[propName] == null) {
            _window[propName] = {};
        }

        let keys = Object.keys(value);

        for (let i = 0; i < keys.length; i++) {
            const key = keys[i];

            if (!(/** @type {{}} */ (_window[propName].hasOwnProperty(key)))) {
                _window[propName][key] = null;
            }

            if (_window[propName][key] == null) {
                _window[propName][key] = value[key];
            }
        }
    } else {
        if (typeof _window[propName] === 'undefined' || _window[propName] == null) {
            _window[propName] = value;
        }
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {{ name: string; value: any; }[]} variables
 */
function exposeGlobalVariables(variables) {
    variables.forEach((variable, index, variables) => {
        try {
            setupWindowProps(getWindow(), variable.name, variable.value);
        } catch (error) {
            logger.error(`Unable to expose variable ${variable.name} into the global scope.`);
        }
    });
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {string} str
 * @returns {string}
 */
function htmlEntitiesDecode(str) {
    return str
        .replace(/&amp;/g, '&')
        .replace(/&lt;/g, '<')
        .replace(/&gt;/g, '>')
        .replace(/&quot;/g, '"');
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {string} metaName
 * @returns {string}
 */
function getMeta(metaName) {
    const metas = document.getElementsByTagName('meta');

    for (let i = 0; i < metas.length; i++) {
        if (metas[i].getAttribute('name') === metaName) {
            return metas[i].getAttribute('content');
        }
    }

    return '';
}

/**
 *
 *
 * @returns {Window & typeof globalThis}
 */
function getWindow() {
    return globalThis.GM_info && GM_info.script.grant.includes('unsafeWindow') ? unsafeWindow : globalThis;
}

/**
 *
 *
 * @param {Window} _window
 */
function getTopWindow(_window = null) {
    _window = _window || getWindow();

    try {
        if (_window.self !== _window.top) {
            _window = getTopWindow(_window.parent);
        }
    } catch (e) {}

    return _window;
}

/**
 * Setup global error handler
 *
 * **Example:**
 * ```javascript
 * setupGlobalErrorHandler({
 *     callback: (error) => console.error('Error:', error),
 *     continuous: true,
 *     prevent_default: true,
 *     tag: '[test-global-error-handler]',
 * });
 * ```
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {{ callback: (error: ErrorEx) => void; continuous?: boolean; prevent_default?: boolean; tag?: string; logFunc?: (...data: any[]) => void; _window: Window; }} [{ callback, continuous = true, prevent_default = false, tag = '[akkd]', logFunc = console.error, _window = window } = {}]
 */
function setupGlobalErrorHandler({ callback, continuous = true, prevent_default = false, tag = null, logFunc = console.error, _window = window } = {}) {
    // respect existing onerror handlers
    let _onerror_original = _window.onerror;

    // install our new error handler
    _window.onerror = function (event, source, lineno, colno, error) {
        if (_onerror_original) {
            _onerror_original(event, source, lineno, colno, error);
        }

        // unset onerror to prevent loops and spamming
        let _onerror = _window.onerror;

        _window.onerror = null;

        // now deal with the error
        let errorObject = new ErrorEx(event, source, lineno, colno, error);
        let errorMessage = createErrorMessage(errorObject);

        if (tag) {
            let rgb = '38;177;38';

            tag = `\x1B[38;2;${rgb}m${tag}\x1B[m`;

            logFunc(tag, errorMessage);
        } else {
            logFunc(errorMessage);
        }

        // run callback if provided
        if (callback) {
            callback(errorObject);
        }

        // re-install this error handler again if continuous mode
        if (continuous) {
            _window.onerror = _onerror;
        }

        // true if normal error propagation should be suppressed
        // (i.e. normally console.error is logged by the browser)
        return prevent_default;
    };

    class ErrorEx {
        /**
         * Creates an instance of ErrorEx.
         * @author Michael Barros <michaelcbarros@gmail.com>
         * @param {string | Event} event
         * @param {string} source
         * @param {number} lineno
         * @param {number} colno
         * @param {Error} error
         * @memberof ErrorEx
         */
        constructor(event, source, lineno, colno, error) {
            this.name = error.name;
            this.message = error && error.message ? error.message : null;
            this.stack = error && error.stack ? error.stack : null;
            this.event = event;
            this.location = document.location.href;
            this.url = source;
            this.lineno = lineno;
            this.colno = colno;
            this.useragent = navigator.userAgent;
            this.fileName = error && error.fileName ? error.fileName : null;
            this.description = error && error.description ? error.description : null;
            this.name = error && error.name ? error.name : null;
            this.error = error;
        }
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {ErrorEx} error
     * @returns {string}
     */
    function createErrorMessage(error) {
        let name = error && error.name ? error.name : 'Error';
        let message = error && error.message ? error.message : 'Unknown error occured';
        let stack = error && error.stack ? error.stack.split('\n').splice(1).join('\n') : 'Error';

        let errorMessage = `Uncaught Global ${name}: ${message}\n${stack}`;

        return errorMessage;
    }
}

function applyCss(cssFiles) {
    /** @type {{ css: string, node?: HTMLElement }[]} */
    let cssArr = [];

    for (let i = 0; i < cssFiles.length; i++) {
        let cssStr = GM_getResourceText(cssFiles[i]);

        cssArr.push({
            css: cssStr,
        });
    }

    addStyles(cssArr);
}

function applyCss2(cssFiles) {
    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {string} cssStyleStr
     * @returns {HTMLStyleElement}
     */
    function createStyleElementFromCss(cssStyleStr) {
        let style = document.createElement('style');

        style.innerHTML = cssStyleStr.trim();

        return style;
    }

    let ranOnce = false;

    /** @type {HTMLStyleElement[]} */
    getWindow().akkd.styleElements = [];

    function removeStyleElements() {
        for (let i = 0; i < getWindow().akkd.styleElements.length; i++) {
            let styleElement = getWindow().akkd.styleElements[i];

            styleElement.remove();
        }

        getWindow().akkd.styleElements = [];
    }

    function _editStyleSheets() {
        $(document).arrive('style, link', async function () {
            if (this.tagName == 'LINK' && this.href.includes('.css')) {
                removeStyleElements();

                for (let i = 0; i < cssFiles.length; i++) {
                    let cssFile = cssFiles[i];
                    let css = GM_getResourceText(cssFile);

                    let styleElem = createStyleElementFromCss(css);

                    styleElem.id = `akkd-transform-style-${(i + 1).toString().padStart(2, '0')}`;

                    getWindow().akkd.styleElements.push(styleElem);

                    document.body.appendChild(styleElem);
                }
            }
        });

        if (!ranOnce) {
            for (let i = 0; i < cssFiles.length; i++) {
                let cssFile = cssFiles[i];
                let css = GM_getResourceText(cssFile);

                let styleElem = createStyleElementFromCss(css);

                styleElem.id = `akkd-transform-style-${(i + 1).toString().padStart(2, '0')}`;

                getWindow().akkd.styleElements.push(styleElem);

                document.body.appendChild(styleElem);
            }

            ranOnce = true;
        }
    }

    _editStyleSheets();
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {{ css: string, node?: HTMLElement }[]} cssArr
 */
let addStyles = (function () {
    /** @type {string[]} */
    const addedStyleIds = [];

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {{ css: string, node?: HTMLElement }[]} cssArr
     * @param {{ useGM: boolean }} cssArr { useGM = true } = {}
     */
    function _addStyles(cssArr, { useGM = true } = {}) {
        /**
         *
         *
         * @author Michael Barros <michaelcbarros@gmail.com>
         * @param {string} css
         * @returns {HTMLStyleElement}
         */
        function createStyleElementFromCss(css) {
            let style = document.createElement('style');

            style.innerHTML = css.trim();

            return style;
        }

        function removeStyleElements() {
            for (let i = addedStyleIds.length - 1; i >= 0; i--) {
                /** @type {HTMLStyleElement} */
                let styleElem = document.getElementById(addedStyleIds[i]);

                if (styleElem) {
                    styleElem.remove();

                    addedStyleIds.splice(i, 1);
                }
            }
        }

        function addStyleElements() {
            for (let i = 0; i < cssArr.length; i++) {
                try {
                    const css = cssArr[i].css;
                    const node = cssArr[i].node || document.head;

                    /** @type {HTMLStyleElement} */
                    let elem = useGM ? GM_addStyle(css) : createStyleElementFromCss(css);

                    elem.id = `akkd-custom-style-${(i + 1).toString().padStart(2, '0')}`;

                    node.append(elem);

                    addedStyleIds.push(elem.id);
                } catch (error) {
                    console.error(error);
                }
            }
        }

        removeStyleElements();
        addStyleElements();

        return addedStyleIds;
    }

    return _addStyles;
})();

/**
 * Return uuid of form xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @returns {string}
 */
function uuid4() {
    let uuid = '';
    let ii;

    for (ii = 0; ii < 32; ii += 1) {
        switch (ii) {
            case 8:
            case 20:
                uuid += '-';
                uuid += ((Math.random() * 16) | 0).toString(16);

                break;

            case 12:
                uuid += '-';
                uuid += '4';

                break;

            case 16:
                uuid += '-';
                uuid += ((Math.random() * 4) | 8).toString(16);

                break;

            default:
                uuid += ((Math.random() * 16) | 0).toString(16);
        }
    }

    return uuid;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {{} | []} obj
 * @param {string | {oldName: string, newName: string}[]} oldName
 * @param {string=} newName
 * @returns
 */
function renameProperty(obj, oldName, newName) {
    function _renameProperty(obj, oldName, newName) {
        let keys = Object.keys(obj);

        for (let i = 0; i < keys.length; i++) {
            let key = keys[i];
            let value = obj[key];

            if (value && typeof value == 'object') {
                obj[key] = _renameProperty(value, oldName, newName);
            }

            if (obj.hasOwnProperty(oldName)) {
                obj[newName] = obj[oldName];

                delete obj[oldName];
            }
        }

        return obj;
    }

    let renames = Array.isArray(oldName) ? oldName : [{ oldName, newName }];

    for (let i = 0; i < renames.length; i++) {
        const rename = renames[i];

        obj = _renameProperty(obj, rename.oldName, rename.newName);
    }

    return obj;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {Promise<T>} fn
 * @param {{ retries?: number; interval?: number; maxTime?: number; throwError?: boolean; }} { retries = 3, interval = 100, maxTime = null, throwError = false }
 * @returns {Promise<T>}
 * @template T
 */
async function retry(fn, { retries = 3, interval = 100, maxTime = null, throwError = false }) {
    let start = new Date();
    let timeLapsed;

    async function _retry() {
        try {
            return await fn;
        } catch (error) {
            timeLapsed = new Date() - start;

            await wait(interval);

            if (maxTime) {
                if (timeLapsed >= maxTime) {
                    if (throwError) {
                        throw error;
                    } else {
                        return null;
                    }
                }
            } else {
                --retries;

                if (retries === 0) {
                    if (throwError) {
                        throw error;
                    } else {
                        return null;
                    }
                }
            }

            return await _retry();
        }
    }

    return await _retry();
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {string} htmlStr
 * @returns {NodeListOf<ChildNode>}
 */
function createElementsFromHTML(htmlStr) {
    let div = document.createElement('div');

    div.innerHTML = htmlStr.trim();

    return div.childNodes;
}

/**
 * Checks if a variable is a number.
 *
 * @param {*} variable - The variable to check.
 * @returns {boolean} - `true` if the variable is a number or a number represented as a string, `false` otherwise.
 */
function isNumber(variable) {
    return (typeof variable == 'string' || typeof variable == 'number') && !isNaN(variable - 0) && variable !== '';
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {string | number} val
 * @returns
 */
function parseNumberSafe(val) {
    if (isNumber(val)) {
        val = parseFloat(val);
    }

    return val;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {number} num
 * @returns {boolean}
 */
function isInt(num) {
    return Number(num) === num && num % 1 === 0;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {number} num
 * @returns {boolean}
 */
function isFloat(num) {
    return Number(num) === num && num % 1 !== 0;
}

/*
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {HTMLElement} elem
 * @param {string} prop
 * @param {Window=} _window
 * @returns {string | number | null}
 */
/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {HTMLElement} elem
 * @param {string} prop
 * @param {Window} [_window=getWindow()]
 * @returns {string | number | null}
 */
function getStyle(elem, prop, _window = null) {
    _window = _window || getWindow();

    let value = parseNumberSafe(
        window
            .getComputedStyle(elem, null)
            .getPropertyValue(prop)
            .replace(/^(\d+)px$/, '$1')
    );

    return value;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {HTMLElement} beforeElem
 * @param {HTMLElement=} afterElem
 */
function attachHorizontalResizer(beforeElem, afterElem) {
    let resizer = document.createElement('span');

    resizer.className = 'akkd-horz-resizer';

    beforeElem.after(resizer);

    afterElem = afterElem ? afterElem : resizer.nextElementSibling;

    // resizer.addEventListener('mousedown', init, false);

    // /**
    //  *
    //  *
    //  * @author Michael Barros <michaelcbarros@gmail.com>
    //  * @param {MouseEvent} ev
    //  */
    // function init(ev) {
    //     getWindow().addEventListener('mousemove', resize, false);
    //     getWindow().addEventListener('mouseup', stopResize, false);
    // }

    // /**
    //  *
    //  *
    //  * @author Michael Barros <michaelcbarros@gmail.com>
    //  * @param {MouseEvent} ev
    //  */
    // function resize(ev) {
    //     beforeElem.style.height = `${ev.clientY - beforeElem.offsetTop}px`;
    // }

    // /**
    //  *
    //  *
    //  * @author Michael Barros <michaelcbarros@gmail.com>
    //  * @param {MouseEvent} ev
    //  */
    // function stopResize(ev) {
    //     getWindow().removeEventListener('mousemove', resize, false);
    //     getWindow().removeEventListener('mouseup', stopResize, false);
    // }

    let prevX = -1;
    let prevY = -1;
    let dir = null;

    $(resizer).on('mousedown', function (e) {
        prevX = e.clientX;
        prevY = e.clientY;
        dir = 'n'; // $(this).attr('id');

        $(document).on('mousemove', resize);
        $(document).on('mouseup', stopResize);
    });

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {JQuery.MouseMoveEvent<Document, undefined, Document, Document>} ev
     */
    function resize(ev) {
        if (prevX == -1) return;

        let boxX = $(afterElem).position().left;
        let boxY = $(afterElem).position().top;
        let boxW = $(afterElem).width();
        let boxH = $(afterElem).height();

        let dx = ev.clientX - prevX;
        let dy = ev.clientY - prevY;

        switch (dir) {
            case 'n':
                // north
                boxY += dy;
                boxH -= dy;

                break;

            case 's':
                // south
                boxH += dy;

                break;

            case 'w':
                // west
                boxX += dx;
                boxW -= dx;

                break;

            case 'e':
                // east
                boxW += dx;

                break;

            default:
                break;
        }

        $(afterElem).css({
            // top: boxY + 'px',
            // left: boxX + 'px',
            // width: boxW + 'px',
            height: boxH + 'px',
        });

        let lines = [
            //
            // ['newHeight', newHeight],
            ['clientY', ev.clientY],
            ['beforeElem.top', roundNumber($(beforeElem).position().top)],
            ['beforeElem.height', $(beforeElem).height()],
            '',
            ['afterElem.top', roundNumber($(afterElem).position().top)],
            ['afterElem.height', $(afterElem).height()],
        ];

        // writeDebugMsg(lines);
        console.debug([`y: ${ev.clientY}`, `b.top: ${roundNumber($(beforeElem).position().top)}`, `b.height: ${$(beforeElem).height()}`, `a.top: ${roundNumber($(afterElem).position().top)}`, `a.height: ${$(afterElem).height()}`].join('    '));

        function writeDebugMsg(lines) {
            let outputLines = ['*'.repeat(60)];

            let tags = lines.map((line) => (Array.isArray(line) ? line[0] : line));

            lines.forEach((line) => {
                if (Array.isArray(line)) {
                    // need to require lpad-align
                    // outputLines.push(`${lpadAlign(line[0], tags)}: ${line[1]}`);
                } else {
                    outputLines.push(line);
                }
            });

            outputLines.push('*'.repeat(60));

            console.debug(outputLines.join('\n'));
        }

        function roundNumber(num, places = 2) {
            return parseFloat(parseFloat(num.toString()).toFixed(places));
            // Math.round(num * 100) / 100
        }

        prevX = ev.clientX;
        prevY = ev.clientY;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {JQuery.MouseMoveEvent<Document, undefined, Document, Document>} ev
     */
    function stopResize(ev) {
        prevX = -1;
        prevY = -1;

        $(document).off('mousemove', resize);
        $(document).off('mouseup', stopResize);
    }
}

function traceMethodCalls(obj) {
    /** @type {ProxyHandler} */
    let handler = {
        get(target, propKey, receiver) {
            if (propKey == 'isProxy') return true;

            const prop = target[propKey];

            if (typeof prop == 'undefined') return;

            if (typeof prop === 'object' && target[propKey] !== null) {
                if (!prop.isProxy) {
                    target[propKey] = new Proxy(prop, handler);

                    return target[propKey];
                } else {
                    return target[propKey];
                }
            }

            if (typeof target[propKey] == 'function') {
                const origMethod = target[propKey];

                return function (...args) {
                    let result = origMethod.apply(this, args);

                    console.log(propKey + JSON.stringify(args) + ' -> ' + JSON.stringify(result));

                    return result;
                };
            } else {
                return target[propKey];
            }
        },
    };

    return new Proxy(obj, handler);
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {HTMLElement} elem
 * @param {number} [topOffset=0]
 * @returns {boolean}
 */
function isVisible(elem, topOffset = 0) {
    /**
     * Checks if a DOM element is visible. Takes into
     * consideration its parents and overflow.
     *
     * @param {HTMLElement} el      the DOM element to check if is visible
     *
     * These params are optional that are sent in recursively,
     * you typically won't use these:
     *
     * @param {number} top       Top corner position number
     * @param {number} right     Right corner position number
     * @param {number} bottom    Bottom corner position number
     * @param {number} left      Left corner position number
     * @param {number} width     Element width number
     * @param {number} height    Element height number
     * @returns {boolean}
     */
    function _isVisible(el, top, right, bottom, left, width, height) {
        let parent = el.parentNode;
        let VISIBLE_PADDING = 2;

        if (!_elementInDocument(el)) {
            return false;
        }

        // Return true for document node
        if (9 === parent.nodeType) {
            return true;
        }

        // Return false if our element is invisible
        if ('0' === _getStyle(el, 'opacity') || 'none' === _getStyle(el, 'display') || 'hidden' === _getStyle(el, 'visibility')) {
            return false;
        }

        if ('undefined' === typeof top || 'undefined' === typeof right || 'undefined' === typeof bottom || 'undefined' === typeof left || 'undefined' === typeof width || 'undefined' === typeof height) {
            top = el.offsetTop + topOffset;
            left = el.offsetLeft;
            bottom = top + el.offsetHeight;
            right = left + el.offsetWidth;
            width = el.offsetWidth;
            height = el.offsetHeight;
        }

        // If we have a parent, let's continue:
        if (parent) {
            // Check if the parent can hide its children.
            if ('hidden' === _getStyle(parent, 'overflow') || 'scroll' === _getStyle(parent, 'overflow')) {
                // Only check if the offset is different for the parent
                if (
                    // If the target element is to the right of the parent elm
                    left + VISIBLE_PADDING > parent.offsetWidth + parent.scrollLeft ||
                    // If the target element is to the left of the parent elm
                    left + width - VISIBLE_PADDING < parent.scrollLeft ||
                    // If the target element is under the parent elm
                    top + VISIBLE_PADDING > parent.offsetHeight + parent.scrollTop ||
                    // If the target element is above the parent elm
                    top + height - VISIBLE_PADDING < parent.scrollTop
                ) {
                    // Our target element is out of bounds:
                    return false;
                }
            }
            // Add the offset parent's left/top coords to our element's offset:
            if (el.offsetParent === parent) {
                left += parent.offsetLeft;
                top += parent.offsetTop;
            }
            // Let's recursively check upwards:
            return _isVisible(parent, top, right, bottom, left, width, height);
        }

        return true;
    }

    // Cross browser method to get style properties:
    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {HTMLElement} el
     * @param {string} property
     * @returns
     */
    function _getStyle(el, property) {
        let value;

        if (window.getComputedStyle) {
            value = document.defaultView.getComputedStyle(el, null)[property];
        }

        if (el.currentStyle) {
            value = el.currentStyle[property];
        }

        return value;
    }

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {HTMLElement} element
     * @returns {boolean}
     */
    function _elementInDocument(element) {
        while ((element = element.parentNode)) {
            if (element == document) {
                return true;
            }
        }

        return false;
    }

    return _isVisible(elem);
}

/**
 * @summary
 * High-order function that memoizes a function, by creating a scope
 * to store the result of each function call, returning the cached
 * result when the same inputs is given.
 *
 * @description
 * Memoization is an optimization technique used primarily to speed up
 * functions by storing the results of expensive function calls, and returning
 * the cached result when the same inputs occur again.
 *
 * Each time a memoized function is called, its parameters are used as keys to index the cache.
 * If the index (key) is present, then it can be returned, without executing the entire function.
 * If the index is not cached, then all the body of the function is executed, and the result is
 * added to the cache.
 *
 * @see https://www.sitepoint.com/implementing-memoization-in-javascript/
 *
 * @export
 * @param {Function} func: function to memoize
 * @returns {Function}
 */
function memoize(func) {
    const cache = {};

    function memoized(...args) {
        const key = JSON.stringify(args);

        if (key in cache) return cache[key];

        if (globalThis instanceof this.constructor) {
            return (cache[key] = func.apply(null, args));
        } else {
            return (cache[key] = func.apply(this, args));
        }
    }

    memoized.toString = () => func.toString();

    return memoized;
}

function memoizeClass(clazz, options = { toIgnore: [] }) {
    let funcs = getObjProps(clazz, { namesOnly: true }).funcs;

    for (let i = 0; i < funcs.length; i++) {
        let funcName = funcs[i];

        if (options.toIgnore.includes(funcName)) continue;

        let func = Object.getOwnPropertyDescriptor(clazz.prototype, funcName);

        let memFunc = memoize(func.value);

        Object.defineProperty(clazz.prototype, funcName, {
            get: function () {
                return memFunc;
            },
        });
    }

    let props = getObjProps(clazz, { namesOnly: true }).props;

    for (let i = 0; i < props.length; i++) {
        let propName = props[i];

        if (options.toIgnore.includes(propName)) continue;

        let prop = Object.getOwnPropertyDescriptor(clazz.prototype, propName);
        let cacheKey = `_${propName}-cache_`;

        Object.defineProperty(clazz.prototype, propName, {
            get: function () {
                return (this[cacheKey] = this[cacheKey] || prop.get.call(this));
            },
        });
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {any[]} objects
 * @returns {object}
 */
function merge(...objects) {
    const isObject = (obj) => Object.prototype.toString.call(obj) == '[object Object]' && obj.constructor && obj.constructor.name == 'Object';

    let _merge = (_target, _source, _isMergingArrays) => {
        if (!isObject(_target) || !isObject(_source)) {
            return _source;
        }

        Object.keys(_source).forEach((key) => {
            const targetValue = _target[key];
            const sourceValue = _source[key];

            if (Array.isArray(targetValue) && Array.isArray(sourceValue)) {
                if (_isMergingArrays) {
                    _target[key] = targetValue.map((x, i) => (sourceValue.length <= i ? x : _merge(x, sourceValue[i], _isMergingArrays)));

                    if (sourceValue.length > targetValue.length) {
                        _target[key] = _target[key].concat(sourceValue.slice(targetValue.length));
                    }
                } else {
                    _target[key] = targetValue.concat(sourceValue);
                }
            } else if (isObject(targetValue) && isObject(sourceValue)) {
                _target[key] = _merge(Object.assign({}, targetValue), sourceValue, _isMergingArrays);
            } else {
                _target[key] = sourceValue;
            }
        });

        return _target;
    };

    const isMergingArrays = typeof objects[objects.length - 1] == 'boolean' ? objects.pop() : false;

    if (objects.length < 2) throw new Error('mergeEx: this function expects at least 2 objects to be provided');

    if (objects.some((object) => !isObject(object))) throw new Error('mergeEx: all values should be of type "object"');

    const target = objects.shift();
    let source;

    while ((source = objects.shift())) {
        _merge(target, source, isMergingArrays);
    }

    return target;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {Window} [windowOrFrame=getTopWindow()]
 * @param {Window[]} [allFrameArray=[]]
 * @returns {Window[]}
 */
function getAllFrames(windowOrFrame = getTopWindow(), allFrameArray = []) {
    allFrameArray.push(windowOrFrame.frames);

    for (var i = 0; i < windowOrFrame.frames.length; i++) {
        getAllFrames(windowOrFrame.frames[i], allFrameArray);
    }

    return allFrameArray;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @returns {boolean}
 */
function windowIsFocused() {
    let frames = getAllFrames();

    for (let i = 0; i < frames.length; i++) {
        const frame = frames[i];

        try {
            if (frame.document.hasFocus()) {
                return true;
            }
        } catch (error) {}
    }

    return false;
}

function setupWindowHasFocused() {
    let frames = getAllFrames();

    for (let i = 0; i < frames.length; i++) {
        const frame = frames[i];

        try {
            frame.hasFocus = windowIsFocused;
        } catch (error) {}
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {[]} array
 * @param {any} element
 * @param {number} index
 */
function moveArrayElement(array, filter, index) {
    let item = array.filter((item) => item === filter)[0];

    if (item) {
        array = array.filter((item) => item !== filter);

        array.unshift(item);
    }

    return array;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {HTMLElement} elem
 * @param {(elem: HTMLElement, level: number) => void} callback
 * @param {number} [level=0]
 */
function walkDom(elem, callback, level = 0) {
    let children = elem.children;

    callback(elem, level);

    for (let i = 0; i < children.length; i++) {
        /** @type {HTMLElement} */
        let child = children[i];

        walkDom(child, callback, level + 1);

        if (child.shadowRoot) {
            walkDom(child.shadowRoot, callback, level + 2);
        }
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {T} obj
 * @param {(key: string | number, value: any, keyPath: string, callbackRes: { doBreak: boolean, returnValue: any | null }, obj: T) => boolean} callback
 * @template T
 * @returns {{ dottedObj: T, returnValue: any }}
 */
function walkObj(obj, callback) {
    let callbackRes = {
        doBreak: false,
        returnValue: null,
    };

    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {{}} _obj
     * @param {string[]} keyPath
     * @param {{}} newObj
     */
    function _walk(_obj, keyPath, newObj) {
        keyPath = typeof keyPath === 'undefined' ? [] : keyPath;
        newObj = typeof newObj === 'undefined' ? {} : newObj;

        for (let key in _obj) {
            if (_obj.hasOwnProperty(key)) {
                let value = _obj[key];

                keyPath.push(key);

                callback.apply(this, [key, value, keyPath.join('.'), callbackRes, obj]);

                if (typeof value === 'object' && value !== null) {
                    newObj = _walk(value, keyPath, newObj);
                } else {
                    let newKey = keyPath.join('.');

                    newObj[newKey] = value;
                }

                keyPath.pop();

                if (callbackRes.doBreak) {
                    break;
                }
            }
        }

        return newObj;
    }

    let newObj = _walk(obj);

    return {
        dottedObj: newObj,
        returnValue: callbackRes.returnValue,
    };
}

/**
 * A function to take a string written in dot notation style, and use it to
 * find a nested object property inside of an object.
 *
 * Useful in a plugin or module that accepts a JSON array of objects, but
 * you want to let the user specify where to find various bits of data
 * inside of each custom object instead of forcing a standardized
 * property list.
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {{}} obj
 * @param {string} dotPath
 * @returns {*}
 */
function getNestedDot(obj, dotPath) {
    let parts = dotPath.split('.');
    let length = parts.length;
    let property = obj || this;

    for (let i = 0; i < length; i++) {
        property = property[parts[i]];
    }

    return property;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {{}} obj
 * @param {number} maxLevel
 */
function getDottedObj(obj, maxLevel = 50) {
    /**
     *
     *
     * @author Michael Barros <michaelcbarros@gmail.com>
     * @param {{}} _obj
     * @param {string[]} keyPath
     * @param {{}} newObj
     * @param {number} level
     */
    function _worker(_obj, keyPath, newObj, level = 0) {
        keyPath = typeof keyPath === 'undefined' ? [] : keyPath;
        newObj = typeof newObj === 'undefined' ? {} : newObj;

        for (let key in _obj) {
            if (_obj.hasOwnProperty(key)) {
                let value = _obj[key];

                keyPath.push(key);

                if (typeof value === 'object' && value !== null) {
                    newObj = _worker(value, keyPath, newObj, level++);
                } else {
                    let newKey = keyPath.join('.');

                    newObj[newKey] = value;
                }

                keyPath.pop();

                if (maxLevel > 0 && level >= maxLevel) {
                    break;
                }
            }
        }

        return newObj;
    }

    let dottedObj = _worker(obj);

    return dottedObj;
}

function isNode() {
    return !(typeof window !== 'undefined' && typeof window.document !== 'undefined');
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @returns {number}
 */
function getCurrentTimeMs() {
    if (isNode()) {
        const NS_PER_MS = 1e6;

        let time = process.hrtime();

        return time[0] * 1000 + time[1] / NS_PER_MS;
    } else {
        return performance.now();
    }
}

const intervalIDsMap = new Map();
const timeoutIDsMap = new Map();

/**
 * Schedules the repeated execution of a function (callback) with a fixed time delay between each call.
 * @param {TimerHandler} handler - A function to be executed repeatedly.
 * @param {number} [timeout] - The time, in milliseconds, between each function call. Default is 0.
 * @param {...any} [args] - Additional arguments to be passed to the function.
 * @returns {number} - An identifier representing the interval. This value can be used with clearInterval to cancel the interval.
 */
function setIntervalEx(handler, timeout, ...args) {
    if (isNode()) {
        return setInterval(handler, timeout, ...args);
    } else {
        let startTime = getCurrentTimeMs();
        let elapsedTime = 0;
        /** @type {number} */
        let intervalId;
        let intervalIdTemp;

        function loop(currentTime) {
            if (intervalIDsMap.get(intervalId)) {
                const deltaTime = currentTime - startTime;

                elapsedTime += deltaTime;

                if (elapsedTime >= timeout) {
                    handler(...args);

                    elapsedTime = 0;
                }

                startTime = currentTime;

                intervalIdTemp = window.requestAnimationFrame(loop);
            } else {
                window.cancelAnimationFrame(intervalIdTemp);
                intervalIDsMap.delete(intervalId);
            }
        }

        intervalId = window.requestAnimationFrame(loop);

        intervalIDsMap.set(intervalId, true);

        return intervalId;
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {number} intervalId
 */
function clearIntervalEx(intervalId) {
    if (isNode()) {
        clearInterval(intervalId);
    } else {
        intervalIDsMap.set(intervalId, false);

        window.cancelAnimationFrame(intervalId);
    }
}

/**
 * Schedules the execution of a function (callback) after a specified time delay.
 * @param {TimerHandler} handler - A function to be executed.
 * @param {number} [timeout] - The time, in milliseconds, to wait before executing the function. Default is 0.
 * @param {...any} [args] - Additional arguments to be passed to the function.
 * @returns {number} - An identifier representing the timeout. This value can be used with clearTimeout to cancel the timeout.
 */
function setTimeoutEx(handler, timeout, ...args) {
    if (isNode()) {
        return setTimeout(handler, timeout, ...args);
    } else {
        let startTime = getCurrentTimeMs();
        /** @type {number} */
        let timeoutId;
        let timeoutIdTemp;

        function loop(currentTime) {
            if (timeoutIDsMap.get(timeoutId)) {
                const deltaTime = currentTime - startTime;

                if (deltaTime >= timeout) {
                    handler(...args);
                } else {
                    timeoutIdTemp = window.requestAnimationFrame(loop);
                }
            } else {
                window.cancelAnimationFrame(timeoutIdTemp);
                timeoutIDsMap.delete(timeoutId);
            }
        }

        timeoutId = window.requestAnimationFrame(loop);

        timeoutIDsMap.set(timeoutId, true);

        return timeoutId;
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {number} timeoutId
 */
function clearTimeoutEx(timeoutId) {
    if (isNode()) {
        clearTimeout(timeoutId);
    } else {
        timeoutIDsMap.set(timeoutId, false);

        window.cancelAnimationFrame(timeoutId);
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {string} attribute
 * @param {string} value
 * @param {string} elementType
 * @returns {HTMLElement[]}
 */
function findByAttributeValue(attribute, value, elementType) {
    elementType = elementType || '*';

    let all = document.getElementsByTagName(elementType);
    let foundElements = [];

    for (let i = 0; i < all.length; i++) {
        if (all[i].getAttribute(attribute).includes(value)) {
            foundElements.push(all[i]);
        }
    }

    return foundElements;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @returns {number}
 */
function getLocalStorageSize() {
    let total = 0;

    for (let x in localStorage) {
        // Value is multiplied by 2 due to data being stored in `utf-16` format, which requires twice the space.
        let amount = localStorage[x].length * 2;

        if (!isNaN(amount) && localStorage.hasOwnProperty(x)) {
            total += amount;
        }
    }

    return total;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {number} bytes
 * @param {boolean} [si=false]
 * @returns {string}
 */
function bytes2HumanReadable(bytes, si = false) {
    let thresh = si ? 1000 : 1024;

    if (Math.abs(bytes) < thresh) {
        return bytes + ' B';
    }

    let units = si ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
    let u = -1;

    while (true) {
        bytes = bytes / thresh;
        u++;

        if (!(Math.abs(bytes) >= thresh && u < units.length - 1)) {
            break;
        }
    }

    return bytes.toFixed(1) + ' ' + units[u];
}

async function GM_fetch(url, fetchInit = {}) {
    if (!window.GM_xmlhttpRequest) {
        console.warn('GM_xmlhttpRequest not required. Using native fetch.');

        return await fetch(url, fetchInit);
    }

    let parseHeaders = function (headersString) {
        const headers = new Headers();

        for (const line of headersString.trim().split('\n')) {
            const [key, ...valueParts] = line.split(':');

            if (key) {
                headers.set(key.trim().toLowerCase(), valueParts.join(':').trim());
            }
        }

        return headers;
    };

    const defaultFetchInit = { method: 'get' };
    const { headers, method } = { ...defaultFetchInit, ...fetchInit };
    const isStreamSupported = GM_xmlhttpRequest?.RESPONSE_TYPE_STREAM;
    const HEADERS_RECEIVED = 2;

    if (!isStreamSupported) {
        return new Promise((resolve, _reject) => {
            const blobPromise = new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    url,
                    method,
                    headers,
                    responseType: 'blob',
                    onload: async (response) => resolve(response.response),
                    onerror: reject,
                    onreadystatechange: onHeadersReceived,
                });
            });

            blobPromise.catch(_reject);

            function onHeadersReceived(gmResponse) {
                const { readyState, responseHeaders, status, statusText } = gmResponse;

                if (readyState === HEADERS_RECEIVED) {
                    const headers = parseHeaders(responseHeaders);

                    resolve({
                        headers,
                        status,
                        statusText,
                        arrayBuffer: () => blobPromise.then((blob) => blob.arrayBuffer()),
                        blob: () => blobPromise,
                        json: () => blobPromise.then((blob) => blob.text()).then((text) => JSON.parse(text)),
                        text: () => blobPromise.then((blob) => blob.text()),
                    });
                }
            }
        });
    } else {
        return new Promise((resolve, _reject) => {
            const responsePromise = new Promise((resolve, reject) => {
                void GM_xmlhttpRequest({
                    url,
                    method,
                    headers,
                    responseType: 'stream',
                    onerror: reject,
                    onreadystatechange: onHeadersReceived,
                    // onloadstart: (gmResponse) => logDebug('[onloadstart]', gmResponse), // debug
                });
            });

            responsePromise.catch(_reject);

            function onHeadersReceived(gmResponse) {
                const { readyState, responseHeaders, status, statusText, response: readableStream } = gmResponse;

                if (readyState === HEADERS_RECEIVED) {
                    const headers = parseHeaders(responseHeaders);
                    let newResp;

                    if (status === 0) {
                        newResp = new Response(readableStream, { headers /*status, statusText*/ });
                    } else {
                        newResp = new Response(readableStream, { headers, status, statusText });
                    }

                    resolve(newResp);
                }
            }
        });
    }
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {T} items
 * @param {{name: string, desc: boolean, case_sensitive: boolean}[]} columns
 * @param {{cmpFunc: any}} [{ cmpFunc = cmp }={}]
 * @returns {T}
 * @template T
 */
function multiKeySort(items, columns, { cmpFunc = null } = {}) {
    function cmp(a, b) {
        if (a < b) {
            return -1;
        } else {
            if (a > b) {
                return 1;
            } else {
                return 0;
            }
        }
    }

    cmpFunc = cmpFunc != null ? cmpFunc : cmp;

    let comparers = [];

    columns.forEach((col) => {
        let column = col.name;
        let desc = 'desc' in col ? col.desc : false;
        let case_sensitive = 'case_sensitive' in col ? col.case_sensitive : true;

        comparers.push([column, desc ? -1 : 1, case_sensitive]);
    });

    function comparer(left, right) {
        for (let i = 0; i < comparers.length; i++) {
            const column = comparers[i][0];
            const polarity = comparers[i][1];
            const case_sensitive = comparers[i][2];

            let result = 0;

            if (case_sensitive) {
                result = cmpFunc(left[column], right[column]);
            } else {
                result = cmpFunc(left[column].toLowerCase(), right[column].toLowerCase());
            }

            if (result) {
                return polarity * result;
            }
        }

        return 0;
    }

    return items.sort(comparer);
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {string} text
 * @param {string} [nodeType='div']
 * @returns {HTMLElement}
 */
function getElementByTextContent(text, nodeType = 'div') {
    let xpath = `//${nodeType}[text()='${text}']`;

    /** @type {HTMLElement} */
    let elem = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;

    return elem;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {string} text
 * @param {string} [nodeType='div']
 * @returns {HTMLElement}
 */
function getElementByTextContentContains(text, nodeType = 'div') {
    let xpath = `//${nodeType}[contains(text(),'${text}')]`;

    /** @type {HTMLElement} */
    let elem = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;

    return elem;
}

/**
 *
 *
 * @author Michael Barros <michaelcbarros@gmail.com>
 * @param {HTMLElement} element
 * @param {(elem: HTMLElement) => void} callback
 */
function onRemove(element, callback) {
    const parent = element.parentNode;

    if (!parent) {
        throw new Error('The node must already be attached');
    }

    const observer = new MutationObserver((mutations) => {
        let removed = false;

        for (const mutation of mutations) {
            for (const node of mutation.removedNodes) {
                if (node === element) {
                    observer.disconnect();

                    callback(element);

                    removed = true;

                    break;
                }
            }

            if (removed) {
                break;
            }
        }
    });

    observer.observe(parent, {
        childList: true,
    });
}

// #endregion Helper Functions

// #region Prototype Functions

// #region Array

/**
 * Function to setup custom prototype functions for the Array class
 * (For no conflicts, Object.defineProperty must be used)
 *
 */
function setupArrayPrototypes() {
    let funcs = [
        function pushUnique(item) {
            let index = -1;

            for (let i = 0; i < this.length; i++) {
                if (equals(this[i], item)) index = i;
            }

            if (index === -1) this.push(item);
        },
    ];

    for (let i = 0; i < funcs.length; i++) {
        let func = funcs[i];

        Object.defineProperty(Array.prototype, func.name, {
            enumerable: false,
            configurable: true,
            writable: true,
            value: func,
        });
    }
}

setupArrayPrototypes();

// #endregion Array

// #endregion Prototype Functions

// #region jQuery

function setupJqueryExtendedFuncs() {
    if ('jQuery' in getWindow() || 'jQuery' in window) {
        jQuery.fn.extend({
            /**
             *
             *
             * @author Michael Barros <michaelcbarros@gmail.com>
             * @returns {boolean}
             * @this {JQuery<HTMLElement>}
             */
            exists: function exists() {
                return this.length !== 0;
            },

            /**
             *
             *
             * @author Michael Barros <michaelcbarros@gmail.com>
             * @param {() => void} callback
             * @returns {JQuery<HTMLElement>}
             * @this {JQuery<HTMLElement>}
             */
            ready: function ready(callback) {
                let cb = function cb() {
                    return setTimeout(callback, 0, jQuery);
                };

                if (document.readyState !== 'loading') {
                    cb();
                } else {
                    document.addEventListener('DOMContentLoaded', cb);
                }

                return this;
            },

            /**
             *
             *
             * @author Michael Barros <michaelcbarros@gmail.com>
             * @param {string} method
             * @param {{}} options
             * @returns {number | string | null}
             * @this {JQuery<HTMLElement>}
             */
            actual: function actual(method, options) {
                // check if the jQuery method exist
                if (!this[method]) {
                    throw '$.actual => The jQuery method "' + method + '" you called does not exist';
                }

                let defaults = {
                    absolute: false,
                    clone: false,
                    includeMargin: false,
                    display: 'block',
                };

                let configs = jQuery.extend(defaults, options);

                let $target = this.eq(0);
                let fix;
                let restore;

                if (configs.clone === true) {
                    fix = function () {
                        let style = 'position: absolute !important; top: -1000 !important; ';

                        // this is useful with css3pie
                        $target = $target.clone().attr('style', style).appendTo('body');
                    };

                    restore = function () {
                        // remove DOM element after getting the width
                        $target.remove();
                    };
                } else {
                    let tmp = [];
                    let style = '';
                    let $hidden;

                    fix = function () {
                        // get all hidden parents
                        $hidden = $target.parents().addBack().filter(':hidden');
                        style += 'visibility: hidden !important; display: ' + configs.display + ' !important; ';

                        if (configs.absolute === true) {
                            style += 'position: absolute !important; ';
                        }

                        // save the origin style props
                        // set the hidden el css to be got the actual value later
                        $hidden.each(function () {
                            // Save original style. If no style was set, attr() returns undefined
                            let $this = jQuery(this);
                            let thisStyle = $this.attr('style');

                            tmp.push(thisStyle);

                            // Retain as much of the original style as possible, if there is one
                            $this.attr('style', thisStyle ? thisStyle + ';' + style : style);
                        });
                    };

                    restore = function () {
                        // restore origin style values
                        $hidden.each(function (i) {
                            let $this = jQuery(this);
                            let _tmp = tmp[i];

                            if (_tmp === undefined) {
                                $this.removeAttr('style');
                            } else {
                                $this.attr('style', _tmp);
                            }
                        });
                    };
                }

                fix();
                // get the actual value with user specific methed
                // it can be 'width', 'height', 'outerWidth', 'innerWidth'... etc
                // configs.includeMargin only works for 'outerWidth' and 'outerHeight'
                let actual = /(outer)/.test(method) ? $target[method](configs.includeMargin) : $target[method]();

                restore();
                // IMPORTANT, this plugin only return the value of the first element
                return actual;
            },

            /**
             *
             *
             * @author Michael Barros <michaelcbarros@gmail.com>
             * @returns {DOMRect}
             * @this {JQuery<HTMLElement>}
             */
            rect: function rect() {
                return this[0].getBoundingClientRect();
            },

            /**
             *
             *
             * @author Michael Barros <michaelcbarros@gmail.com>
             * @param {number} levels
             * @returns {JQuery<HTMLElement>}
             * @this {JQuery<HTMLElement>}
             */
            parentEx: function parentEx(levels = 1) {
                let parent = this;

                for (let i = 0; i < levels; i++) {
                    if (parent.parent().length == 0) {
                        break;
                    }

                    parent = parent.parent();
                }

                return parent;
            },
        });
    }
}

setupJqueryExtendedFuncs();

// #endregion jQuery