/*
 *┌───────┐
 *│       │
 *│ UTILS │
 *│       │
 *└───────┘
 */

// https://mathjs.org/
import {
    add as mathAdd,
    bignumber as mathBignumber,
    BigNumber,
    divide as mathDivide,
    hasNumericValue as mathIsNumeric,
    multiply as mathMultiply,
    number as mathNumber,
    round as mathRound,
    subtract as mathSubtract,
    unit as mathUnit,
} from 'mathjs';

interface ICurrencyFormat {
    pos : string;
    neg : string;
    zero: string;
}

const Utils = {
	
    /**
     * Utils settings.
     */
    settings: {
        delayTimer: 0,
        number    : {
            precision   : 0,       // Default precision on numbers is 0.
            thousandsSep: ',',     // Default thousands separator.
            decimalsSep : '.',     // Default decimals separator.
        },
        currency: {
            symbol      : '$',     // Default currency symbol.
            format      : '%s%v',  // Controls output: %s = symbol, %v = value (it can be an ICurrencyFormat object).
            decimalsSep : '.',	   // Default decimal point separator.
            thousandsSep: ',',	   // Default thousands separator.
            precision   : 2,	   // Default decimal places.
        },
    },
	
    /**
     * Apply a delay
     *
     * @param {Function} callback
     * @param {number}   ms
     *
     * @return {Function}
     */
    delay( callback: Function, ms: number ) {
		
        clearTimeout( this.settings.delayTimer );
        this.settings.delayTimer = setTimeout( callback, ms );

        return this.settings.delayTimer;
		
    },
	
    /**
     * Filter the URL Query to extract variables
     *
     * @see http://css-tricks.com/snippets/javascript/get-url-variables/
     *
     * @param {string} query    The URL query part containing the variables.
     * @param {string} variable Name of the variable we want to get.
     *
     * @return {string|boolean} The variable value if available, false else.
     */
    filterQuery( query: string, variable: string ): string | boolean {

        const vars: string[] = query.split( '&' );

        for ( let i = 0; i < vars.length; i++ ) {

            const pair = vars[ i ].split( '=' );

            if ( pair[ 0 ] === variable ) {
                return pair[ 1 ];
            }

        }

        return false;

    },

    /**
     * Filter elements by data attributes
     *
     * @param {JQuery} $elem
     * @param {string} prop
     * @param val
     *
     * @return {JQuery}
     */
    filterByData( $elem: JQuery, prop: string, val: any ): JQuery {

        if ( typeof val === 'undefined' ) {
            return $elem.filter( ( index: number, elem: Element ) => {
                return typeof $( elem ).data( prop ) !== 'undefined';
            } );
        }

        return $elem.filter( ( index: number, elem: Element ) => {
            return $( elem ).data( prop ) == val;
        } );

    },
	
    /**
     * Add a notice on top identical to the WordPress' admin notices
     *
     * @param {'success'|'error'|'info'|'warning'} type           The notice type.
     * @param {string}                             msg            The message.
     * @param {boolean}                            autoDismiss    Optional. Dismiss the notice automatically after some seconds. False by default
     * @param {int}                                dismissSeconds Optional. The number of seconds after the auto-dismiss is triggered. 5 by default.
     */
    addNotice( type: 'success' | 'error' | 'info' | 'warning', msg: string, autoDismiss: boolean = false, dismissSeconds: number = 5 ) {

        const $notice: JQuery        = $( `<div class="notice-${ type } notice is-dismissible"><p><strong>${ msg }</strong></p></div>` ).hide(),
              $dismissButton: JQuery = $( '<button />', { type: 'button', class: 'notice-dismiss' } ),
              $headerEnd: JQuery     = $( '.wp-header-end' );

        $headerEnd.siblings( '.notice' ).remove();
        $headerEnd.before( $notice.append( $dismissButton ) );
        $notice.slideDown( 100 );

        $dismissButton.on( 'click.wp-dismiss-notice', ( evt: any ) => {

            evt.preventDefault();

            $notice.fadeTo( 100, 0, () => {
                $notice.slideUp( 100, () => {
                    $notice.remove();
                } );
            } );

        } );

        if ( autoDismiss ) {
            setTimeout( () => {
                $dismissButton.trigger( 'click.wp-dismiss-notice' );
            }, dismissSeconds * 1000 );
        }
		
    },
	
    /**
     * Defer the execution until all the images have been loaded
     *
     * @param {JQuery} $wrapper
     *
     * @return {JQueryPromise<any>}
     */
    imagesLoaded( $wrapper: JQuery ): JQueryPromise<any> {

        // Get all the images (excluding those with no src attribute).
        const $imgs: JQuery = $wrapper.find( 'img[src!=""]' );

        // If there's no images, just return an already resolved promise.
        if ( !$imgs.length ) {
            return $.Deferred().resolve().promise();
        }

        // For each image, add a deferred object to the array which resolves when the image is loaded (or if loading fails)
        let dfds = [];

        $imgs.each( ( index: number, elem: Element ) => {

            let dfd: any = $.Deferred(),
                img: any = new Image();

            dfds.push( dfd );

            img.onload = () => dfd.resolve();
            img.onerror = () => dfd.resolve();
            img.src = $( elem ).attr( 'src' );
			
        } );
		
        /*
         * Return a master promise object which will resolve when all the deferred objects have resolved
         * IE - when all the images are loaded
         */
        return $.when.apply( $, dfds );
		
    },
	
    /**
     * Helper to get parameters from the URL
     *
     * @param {string} name
     *
     * @return {string|string[]}
     */
    getUrlParameter( name: string ): string | string[] {

        if ( typeof URLSearchParams !== 'undefined' ) {
            const urlParams = new URLSearchParams( window.location.search );

            return urlParams.get( name );
        }
        // Deprecated: Only for old browsers non-supporting URLSearchParams.
        else {

            name = name.replace( /[\[]/, '\\[' ).replace( /[\]]/, '\\]' );
            const regex: RegExp     = new RegExp( '[\\?&]' + name + '=([^&#]*)' ),
                  results: string[] = regex.exec( window.location.search );

            return results === null ? '' : decodeURIComponent( results[ 1 ].replace( /\+/g, ' ' ) );

        }

    },

    /**
     * Converts a query string to an object of params
     *
     * @param {string} query
     *
     * @return {any}
     */
    getQueryParams( query: string ): any {

        let params: any = {};

        new URLSearchParams( query ).forEach( ( value: string, key: string ) => {

            let decodedKey: string = decodeURIComponent( key );
            let decodedValue: string = decodeURIComponent( value );

            // This key is part of an array
            if ( decodedKey.endsWith( '[]' ) ) {
                decodedKey = decodedKey.replace( '[]', '' );
                params[ decodedKey ] || ( params[ decodedKey ] = [] );
                params[ decodedKey ].push( decodedValue );
            }
            // Just a regular parameter
            else {
                params[ decodedKey ] = decodedValue;
            }

        } );

        return params;

    },
	
    /**
     * Get a sanitized HTML code and returns valid HTML code
     *
     * @param {string} input
     *
     * @return {string}
     */
    htmlDecode( input: string ): string {

        const e: HTMLElement = document.createElement( 'div' );

        e.innerHTML = input;

        return e.childNodes[ 0 ].nodeValue;

    },
	
    /**
     * Check whether 2 distinct objects are equivalent (have the same keys and values)
     *
     * @param {any}     a       The first object to compare.
     * @param {any}     b       The second object to compare.
     * @param {boolean} strict  Optional. Whether to compare strictly.
     *
     * @return {boolean}
     */
    areEquivalent( a: any, b: any, strict: boolean = false ): boolean {

        // Create arrays of property names.
        const aProps: string[] = Object.getOwnPropertyNames( a ),
              bProps: string[] = Object.getOwnPropertyNames( b );

        // If number of properties is different, objects are not equivalent.
        if ( aProps.length != bProps.length ) {
            return false;
        }

        for ( let i = 0; i < aProps.length; i++ ) {
            const propName: string = aProps[ i ];

            // If values of same property are not equal, objects are not equivalent.
            if ( ( strict && a[ propName ] !== b[ propName ] ) || ( !strict && a[ propName ] != b[ propName ] ) ) {
                return false;
            }
        }
		
        // If we made it this far, objects are considered equivalent.
        return true;

    },
	
    /**
     * Toggle tree nodes
     *
     * @param {any[]}  nodes
     * @param {string} openOrClose
     */
    toggleNodes( nodes: any[], openOrClose: string ) {

        for ( let i: number = 0; i < nodes.length; i++ ) {

            nodes[ i ].isExpanded = openOrClose == 'open'; // Either expand node or not

            // If it has children, open/close those them as well.
            if ( nodes[ i ].children && nodes[ i ].children.length > 0 ) {
                this.toggleNodes( nodes[ i ].children, openOrClose );
            }

        }

    },

    /**
     * Format a number according to the specified options
     *
     * @param {number}      number
     * @param {number}      precision
     * @param {string}      thousandsSep
     * @param {string}      decimalsSep
     * @param {number|null} minimumSignificantDigits
     *
     * @return {string}
     */
    formatNumber(
        number: number,
        precision: number                       = this.settings.number.precision,
        thousandsSep: string                    = this.settings.number.thousandsSep,
        decimalsSep: string                     = this.settings.number.decimalsSep,
        minimumSignificantDigits: number | null = 1,
    ): string {

        // Make sure the separators are not equal and both required at the same time.
        if ( number > 999 && thousandsSep === decimalsSep && !Number.isInteger( number ) ) {
            thousandsSep = '';
        }

        // Format it to english notation and at least 1 digit.
        const formatOptions: Intl.NumberFormatOptions = {
            minimumFractionDigits: precision,
        };

        if ( minimumSignificantDigits ) {
            formatOptions.minimumSignificantDigits = minimumSignificantDigits;
        }

        const formattedNumber: string = number.toLocaleString( 'en', formatOptions );

        return formattedNumber
            .replace( new RegExp( '\\,', 'g' ), thousandsSep )
            .replace( new RegExp( '\\.' ), decimalsSep );

    },
	
    /**
     * Format a number into currency
     * Based on accounting.js.
     *
     * Usage: Utils.formatMoney( number, symbol, precision, thousandsSep, decimalSep, format )
     * defaults: (0, '$', 2, ',', '.', '%s%v')
     *
     * Localise by overriding the symbol, precision, thousands / decimal separators and format
     */
    formatMoney(
        number: number,
        symbol: string                   = this.settings.currency.symbol,
        precision: number                = this.settings.currency.precision,
        thousandsSep: string             = this.settings.currency.thousandsSep,
        decimalsSep: string              = this.settings.currency.decimalsSep,
        format: string | ICurrencyFormat = this.settings.currency.format,
    ): string {
		
        // Clean up number.
        if ( !this.isNumeric( number ) ) {
            number = this.unformat( number );
        }

        const formats: ICurrencyFormat = this.checkCurrencyFormat( format ), // Check format (returns an ICurrencyFormat object with pos, neg and zero).
              useFormat: string        = number > 0 ? formats.pos : ( number < 0 ? formats.neg : formats.zero ); // Choose which format to use for this value.

        // Return with currency symbol added.
        return useFormat.replace( '%s', symbol ).replace( '%v', this.formatNumber( Math.abs( number ), this.checkPrecision( precision ), thousandsSep, decimalsSep, null ) );
		
    },
	
    /**
     * Takes a string, removes all formatting/cruft and returns the raw float value
     *
     * Decimal must be included in the regular expression to match floats (defaults to
     * Utils.settings.number.decimal), so if the number uses a non-standard decimal
     * separator, provide it as the second argument.
     *
     * Also matches bracketed negatives (ex. "$ (1.99)" => -1.99)
     *
     * Doesn't throw any errors (`NaN`s become 0)
     *
     * @param {string} value
     * @param {string} decimalsSep
     *
     * @return {number}
     */
    unformat( value: string, decimalsSep: string = this.settings.number.decimalsSep ): number {
		
        // Return the value as-is in case it's already a number.
        if ( typeof value === 'number' ) {
            return value;
        }
		
        // Build regex to strip out everything except digits, decimal point and minus sign.
        const regex: RegExp       = new RegExp( `[^0-9-${ decimalsSep }]`, 'g' ),
              unformatted: number = parseFloat(
                  ( '' + value )
                      .replace( /\((.*)\)/, '-$1' ) // Replace bracketed values with negatives
                      .replace( regex, '' )         // Strip out any cruft
                      .replace( decimalsSep, '.' ), // Make sure decimal point is standard
              );
		
        // This will fail silently which may cause trouble, let's wait and see.
        return !isNaN( unformatted ) ? unformatted : 0;
		
    },
	
    /**
     * Check and normalise the value of precision (must be positive integer).
     * Based on accounting.js.
     *
     * @param {number} val
     * @param {number} base
     *
     * @return {number}
     */
    checkPrecision( val: number, base: number = 0 ): number {
        val = Math.round( Math.abs( val ) );

        return isNaN( val ) ? base : val;
    },
	
    /**
     * Parses a format string or object and returns format obj for use in rendering.
     * Based on accounting.js.
     *
     * `format` is either a string with the default (positive) format, or object
     * containing `pos` (required), `neg` and `zero` values (or a function returning
     * either a string or object)
     *
     * Either string or format.pos must contain '%v' (value) to be valid.
     * To add the symbol it must contain a '%s'.
     */
    checkCurrencyFormat( format: ICurrencyFormat | string | Function ): ICurrencyFormat | string {
		
        // Allow function as format parameter (should return string or object).
        if ( typeof format === 'function' ) {
            return format();
        }
        // Format can be a string, in which case `value` ('%v') must be present.
        else if ( typeof format === 'string' && format.match( '%v' ) ) {
			
            // Create and return positive, negative and zero formats.
            return {
                pos : format,
                neg : format.replace( '-', '' ).replace( '%v', '-%v' ),
                zero: format,
            };
			
        }
        // If no format, or object is missing valid positive value, use defaults.
        else if ( !format || ( typeof format === 'object' && ( !format.pos || !format.pos.match( '%v' ) ) ) ) {

            const defaultFormat: string = this.settings.currency.format;
			
            // If defaults is a string, casts it to an object for faster checking next time.
            return ( typeof defaultFormat !== 'string' )
                ? defaultFormat
                : this.settings.currency.format = {
                    pos : defaultFormat,
                    neg : defaultFormat.replace( '%v', '-%v' ),
                    zero: defaultFormat,
                };
			
        }
		
    },

    /**
     * Count number of decimals
     *
     * @param {number} value
     *
     * @return number
     */
    countDecimals( value: number ): number {
        if ( Math.floor( value ) === value ) {
            return 0;
        }

        return value.toString().split( '.' )[ 1 ].length || 0;
    },

    /**
     * Multiply a decimal number and return the right value
     *
     * @param {number|string} multiplicand
     * @param {number|string} multiplier
     *
     * @returns {number}
     */
    multiplyDecimals( multiplicand: number, multiplier: number ): number {
        return mathNumber( <BigNumber>mathMultiply( mathBignumber( this.isNumeric( multiplicand ) ? multiplicand : 0 ), mathBignumber( this.isNumeric( multiplier ) ? multiplier : 0 ) ) );
    },

    /**
     * Divide a decimal number and return the right value
     *
     * @param {number|string} dividend
     * @param {number|string} divisor
     *
     * @returns {number}
     */
    divideDecimals( dividend: number, divisor: number ): number {
        return mathNumber( <BigNumber>mathDivide( mathBignumber( this.isNumeric( dividend ) ? dividend : 0 ), mathBignumber( this.isNumeric( divisor ) ? divisor : 0 ) ) );
    },

    /**
     * Sum two decimal numbers and return the right value
     *
     * @param {number|string} summand1
     * @param {number|string} summand2
     *
     * @returns {number}
     */
    sumDecimals( summand1: number, summand2: number ): number {
        return mathNumber( <BigNumber>mathAdd( mathBignumber( this.isNumeric( summand1 ) ? summand1 : 0 ), mathBignumber( this.isNumeric( summand2 ) ? summand2 : 0 ) ) );
    },

    /**
     * Subtract a decimal number to another and return the right value
     *
     * @param {number|string} minuend
     * @param {number|string} subtrahend
     *
     * @returns {number}
     */
    subtractDecimals( minuend: number, subtrahend: number ): number {
        return mathNumber( <BigNumber>mathSubtract( mathBignumber( this.isNumeric( minuend ) ? minuend : 0 ), mathBignumber( this.isNumeric( subtrahend ) ? subtrahend : 0 ) ) );
    },

    /**
     * Check the parameter is numeric
     * NOTE: Previously we were using the jQuery.isNumeric() function but has been deprecated
     *
     * @param {any} n
     *
     * @return {boolean}
     */
    isNumeric( n: any ): boolean {
        return <boolean>mathIsNumeric( n );
    },

    /**
     * Round a value with the sepcified precision
     *
     * @param {number} n
     * @param {number} precision
     *
     * @return {number}
     */
    round( n: number, precision: number ): number {
        return mathRound( n, precision );
    },

    /**
     * Convert a value from one unit to another
     * Reference: https://mathjs.org/docs/datatypes/units.html#reference
     *
     * @param {number} value
     * @param {string} fromUnit
     * @param {string} toUnit
     */
    convertUnit( value: number, fromUnit: string, toUnit: string ): number {
        return mathNumber( mathUnit( value, fromUnit ), toUnit );
    },

    /**
     * Convert an array of JQuery elements to string
     *
     * @param {JQuery} $elems
     *
     * @return {string}
     */
    convertElemsToString( $elems: JQuery ): string {
        return $( '<div />' ).append( $elems ).html();
    },

    /**
     * Merge 2 arrays and return the result (this ensures the resulting arrar has no repeated values)
     *
     * @param {any[]} arr1
     * @param {any[]} arr2
     *
     * @return {any[]}
     */
    mergeArrays( arr1: any[], arr2: any[] ): any[] {
        return Array.from( new Set( [ ...arr1, ...arr2 ] ) );
    },

    /**
     * Restrict an input number field's values to fit within its allowed range
     *
     * @param {JQuery} $input
     */
    restrictNumberInputValues( $input: JQuery ) {

        if ( $input.attr( 'type' ) !== 'number' ) {
            return; // Only input numbers are allowed.
        }

        const qty: string = $input.val();

        // Make sure the value entered is within the allowed range.
        const value: number              = parseFloat( qty || '0' ),
              minAtt: string | undefined = $input.attr( 'min' ),
              maxAtt: string | undefined = $input.attr( 'max' ),
              min: number                = parseFloat( minAtt || '0' ),
              max: number                = parseFloat( maxAtt || '0' );

        if ( !this.isNumeric( qty ) ) {
            $input.val( undefined !== minAtt && !isNaN( min ) && min > 0 ? min : 0 ); // Set to 0 or min (the greater).
        }
        else if ( undefined !== minAtt && value < min ) {
            $input.val( min ); // Change to min.
        }
        else if ( undefined !== maxAtt && value > max ) {
            $input.val( max ); // Change to max.
        }

    },

    /**
     * Check if the RTL mode is active and add some options on some cases
     *
     * @param {string} value
     *
     * @return {string | boolean}
     */
    checkRTL( value: string ): string | boolean {

        let isRTL: boolean = false;

        if ( $( 'html[ dir="rtl" ]' ).length > 0 ) {
            isRTL = true;
        }

        switch ( value ) {
        case 'isRTL':

        case 'reverse':
            return isRTL;
            break;

        case 'xSide':
            if ( isRTL ) {
                return 'right';
            }
            else {
                return 'left';
            }

            break;

        default:
            return false;
            break;

        }

    },

    /**
     * Calc a base price taxes. Based on WC_Tax::calc_exclusive_tax as we have a price without applied taxes.
     *
     * @param {number} price
     * @param {any[]} rates
     *
     * @return {number}
     */
    calcTaxesFromBase( price: number, rates: any[] ): number {

        let taxes: number[] = [ 0 ],
            preCompoundTaxes: number;

        $.each( rates, ( i: number, rate: any ) => {

            if ( 'yes' === rate[ 'compound' ] ) {
                return true;
            }

            taxes.push( this.divideDecimals( this.multiplyDecimals( price, rate[ 'rate' ] ), 100 ) );

        } );

        preCompoundTaxes = taxes.reduce( ( a: number, b: number ) => this.sumDecimals( a, b ), 0 );

        // Compound taxes.
        $.each( rates, ( i: number, rate: any ) => {

            let currentTax: number;

            if ( 'no' === rate[ 'compound' ] ) {
                return true;
            }

            currentTax = this.divideDecimals( this.multiplyDecimals( this.sumDecimals( price, preCompoundTaxes ), rate[ 'rate' ] ), 100 );
            taxes.push( currentTax );
            preCompoundTaxes = this.sumDecimals( currentTax, preCompoundTaxes );

        } );

        return taxes.reduce( ( a: number, b: number ) => this.sumDecimals( a, b ), 0 );

    },

    /**
     * Detect clicks on pseudo-elements
     *
     * @param {JQueryEventObject}       evt         The click event fired on the parent element.
     * @param {JQuery}                  $parentElem The parent element to which the pseudo-element belongs.
     * @param {'before'|'after'|'both'} pseudoElem  Which pseudo-element to check.
     *
     * @return {{before: boolean, after: boolean} | boolean}
     */
    pseudoClick( evt: JQueryEventObject, $parentElem: JQuery, pseudoElem: 'before' | 'after' | 'both' = 'both' ): { before: boolean; after: boolean } | boolean {

        let beforeClicked: boolean = false,
            afterClicked: boolean  = false;

        const parentElem: HTMLElement = <HTMLElement>$parentElem.get( 0 ),
              parentLeft: number      = parseInt( parentElem.getBoundingClientRect().left.toString(), 10 ),
              parentTop: number       = parseInt( parentElem.getBoundingClientRect().top.toString(), 10 );

        const mouseX: number = evt.clientX,
              mouseY: number = evt.clientY;

        if ( [ 'before', 'both' ].includes( pseudoElem ) ) {

            const before: CSSStyleDeclaration = window.getComputedStyle( parentElem, ':before' ),
                  beforeStart: number         = parentLeft + ( parseInt( before.getPropertyValue( 'left' ), 10 ) ),
                  beforeEnd: number           = beforeStart + parseInt( before.width, 10 ),
                  beforeYStart: number        = parentTop + ( parseInt( before.getPropertyValue( 'top' ), 10 ) ),
                  beforeYEnd: number          = beforeYStart + parseInt( before.height, 10 );

            beforeClicked = mouseX >= beforeStart && mouseX <= beforeEnd && mouseY >= beforeYStart && mouseY <= beforeYEnd;

        }

        if ( [ 'after', 'both' ].includes( pseudoElem ) ) {

            const after: CSSStyleDeclaration = window.getComputedStyle( parentElem, ':after' ),
                  afterStart: number         = parentLeft + ( parseInt( after.getPropertyValue( 'left' ), 10 ) ),
                  afterEnd: number           = afterStart + parseInt( after.width, 10 ),
                  afterYStart: number        = parentTop + ( parseInt( after.getPropertyValue( 'top' ), 10 ) ),
                  afterYEnd: number          = afterYStart + parseInt( after.height, 10 );

            afterClicked = mouseX >= afterStart && mouseX <= afterEnd && mouseY >= afterYStart && mouseY <= afterYEnd;

        }

        switch ( pseudoElem ) {
        case 'after':
            return afterClicked;

        case 'before':
            return beforeClicked;

        default:
            return {
                before: beforeClicked,
                after : afterClicked,
            };
        }

    },

    /**
     * Check whether the passed element is visible in the viewport
     *
     * @param {HTMLElement} el
     *
     * @return {boolean}
     */
    isElementInViewport( el: HTMLElement ) {

        const rect: DOMRect = el.getBoundingClientRect();

        return (
            rect.top >= 0
            && rect.left >= 0
            && rect.bottom + 80 <= window.innerHeight // Add 80 to get the text right
            && rect.right <= window.innerWidth
        );

    },

};

export default Utils;
