/* eslint-disable vars-on-top, no-shadow, @typescript-eslint/no-shadow */
const shorthandVariableRegex = /F(\d+(?:\.\d+)?)/gm;

interface GPAdvancedCalculationsConfig {
	formId: string;
}

interface FormulaField {
	field_id: number;
	formula: string;
	rounding: string | number;
}

type ParsedFormulaItem = (
	| ParsedFormulaOperatorItem
	| ParsedFormulaSepItem
	| ParsedFormulaFunctionItem
	| ParsedFormulaValueItem
	| ParsedFormulaComparisonOpItem
	| ParsedFormulaLogicalOpItem
 ) & {
	depth: number;
};

interface ParsedFormulaSepItem {
	type: 'sep';
	value: ',';
}

type Operator = '*' | '-' | '+' | '/' | '^' | '%';

interface ParsedFormulaOperatorItem {
	type: 'operator';
	value: Operator;
}

interface ParsedFormulaFunctionItem {
	type: 'function';
	value: string;
}

interface ParsedFormulaValueItem {
	type: 'value';
	value: number | string; // String for merge tags
}

interface ParsedFormulaComparisonOpItem {
	type: 'comparisonOp';
	value: string;
}

interface ParsedFormulaLogicalOpItem {
	type: 'logicalOp';
	value: string;
}

type ParsedFormula = ParsedFormulaItem[];

class GPAdvancedCalculations {
	public formId: number;

	/* Used over Math.PI to keep precision consistent between PHP and JS. */
	readonly pi20DecimalPlaces = 3.14159265358979323846;

	constructor( { formId }: GPAdvancedCalculationsConfig ) {
		this.formId = parseInt( formId );

		this.init();
	}

	init = () => {
		window.gform.addFilter(
			'gform_calculation_formula',
			(
				formula: string,
				formulaField: FormulaField,
				formId: string,
				calcObj: any
			) => {
				if ( parseInt( formId ) !== this.formId ) {
					return formula;
				}

				formula = this.replaceShorthandVariables(
					formula,
					formulaField,
					this.formId
				);
				formula = this.processConditionals(
					formula,
					formulaField,
					this.formId,
					calcObj
				);
				formula = this.processExponents(
					formula,
					formulaField,
					this.formId,
					calcObj
				);
				formula = this.processFunctions(
					formula,
					formulaField,
					this.formId,
					calcObj
				);

				return formula;
			},
			15
		);

		jQuery( document ).trigger( 'gform_post_conditional_logic' );

		this.bindShorthandCalcEvents();
		this.addGPPAListener();
	};

	/**
	 * Used for binding the calculation events on fields referenced in formulas using shorthand variables.
	 *
	 * Loosely based on GFCalc.bindCalcEvents
	 */
	bindShorthandCalcEvents = () => {
		const calcObj = window.gf_global.gfcalc[ this.formId ];
		const { formulaFields, formId } = calcObj;

		for ( let i = 0; i < formulaFields.length; i++ ) {
			const formulaField: FormulaField = jQuery.extend(
				{},
				formulaFields[ i ]
			);
			const formulaFieldId = formulaField.field_id;

			const matches = formulaField.formula.match(
				shorthandVariableRegex
			);

			calcObj.isCalculating[ formulaFieldId ] = false;

			for ( const i in matches ) {
				if ( ! matches.hasOwnProperty( i ) ) continue;

				// @ts-ignore
				const inputId = matches[ i ].replace( /^F/, '' );
				const fieldId = parseInt( inputId, 10 );
				const input = jQuery( '#field_' + formId + '_' + fieldId ).find(
					'input[name="input_' +
						inputId +
						'"], select[name="input_' +
						inputId +
						'"]'
				);

				const eventNamespace = `gpacShorthand_${ formulaFieldId }`;

				// Prevent double-binding
				jQuery( input ).off( `.${ eventNamespace }` );

				if (
					input.prop( 'type' ) === 'checkbox' ||
					input.prop( 'type' ) === 'radio'
				) {
					jQuery( input ).on(
						`click.${ eventNamespace }`,
						function () {
							calcObj.bindCalcEvent(
								inputId,
								formulaField,
								formId,
								0
							);
						}
					);
				} else if (
					input.is( 'select' ) ||
					input.prop( 'type' ) === 'hidden'
				) {
					jQuery( input ).on(
						`change.${ eventNamespace }`,
						function () {
							calcObj.bindCalcEvent(
								inputId,
								formulaField,
								formId,
								0
							);
						}
					);
				} else {
					jQuery( input ).on(
						`change.${ eventNamespace } keydown.${ eventNamespace }`,
						function () {
							calcObj.bindCalcEvent(
								inputId,
								formulaField,
								formId,
								0
							);
						}
					);
				}

				// allow users to add custom methods for triggering calculations
				window.gform.doAction(
					'gform_post_calculation_events',
					// @ts-ignore
					[ matches[ i ], inputId ],
					formulaField,
					formId,
					calcObj
				);
			}
		}
	};

	/**
	 * Listen for field's being refreshed by GPPA and rebind as needed.
	 */
	addGPPAListener = () => {
		jQuery( document ).on(
			'gppa_updated_batch_fields',
			( event, formId ) => {
				// eslint-disable-next-line eqeqeq
				if ( this.formId != formId ) {
					return;
				}

				this.bindShorthandCalcEvents();
			}
		);
	};

	processConditionals = (
		formula: string,
		formulaField: any,
		formId: number,
		calcObj: any
	) => {
		// eslint-disable-line no-unused-vars
		let match;

		const regex = /^if\s*\((.+)\)\s*:[\s]+(.+)[\s]+((?:elseif\s*\(.+\)\s*:[\s]+.+[\s]+)*?)else\s*:[\s]+(.+)[\s]+endif\s*;/g,
			elseIfRegex = /(?:elseif\s*\((.+)\)\s*:[\s]+(.+))+/g;

		let modified = formula;

		while ( ( match = regex.exec( formula ) ) !== null ) {
			let elseIfMatch;

			const fullMatch = match[ 0 ],
				elseIfs = match[ 3 ],
				statements = [];

			statements.push( {
				tag: 'if',
				condition: this.replaceMergeTags(
					match[ 1 ],
					formulaField,
					formId
				),
				formula: match[ 2 ],
			} );

			if ( elseIfs ) {
				while (
					( elseIfMatch = elseIfRegex.exec( elseIfs ) ) !== null
				) {
					statements.push( {
						tag: 'elseif',
						condition: this.replaceMergeTags(
							elseIfMatch[ 1 ],
							formulaField,
							formId
						),
						formula: elseIfMatch[ 2 ],
					} );
				}
			}

			statements.push( {
				tag: 'else',
				condition: null,
				formula: match[ 4 ],
			} );

			// @ts-ignore
			jQuery.each( statements, ( i, statement ) => {
				let condition: string | null = statement.condition;

				if ( condition ) {
					condition = this.processExponents(
						condition,
						formulaField,
						this.formId,
						calcObj
					);

					condition = this.processFunctions(
						condition,
						formulaField,
						this.formId,
						calcObj
					);
				}

				if (
					( statement.tag === 'else' &&
						statement.condition === null ) ||
					!! this.evalFormula( condition! )
				) {
					modified = modified.replace( fullMatch, statement.formula );

					return false;
				}
			} );
		}

		return modified;
	};

	replaceShorthandVariables = (
		formula: string,
		formulaField: FormulaField,
		formId: number
	) => {
		// Shorthand Field Variables
		let shorthandVariableMatch;

		while (
			( shorthandVariableMatch = new RegExp(
				shorthandVariableRegex
			).exec( formula ) ) !== null
		) {
			const [ fullMatch, inputId ] = shorthandVariableMatch;

			formula = formula.replace(
				/* Use negative lookahead to prevent partial matches. e.g. if replacing F4, don't replace F43. */
				new RegExp( `${ fullMatch }(?!\d)` ),
				this.replaceMergeTag( inputId, undefined, formulaField, formId )
			);
		}

		return formula;
	};

	replaceMergeTags = (
		formula: string,
		formulaField: any,
		formId: number
	) => {
		// Regular Field Variable Merge Tags
		const matches = window.GFMergeTag.parseMergeTags( formula );

		for ( const i in matches ) {
			if ( ! matches.hasOwnProperty( i ) ) {
				continue;
			}

			formula = formula.replace(
				matches[ i ][ 0 ],
				this.replaceMergeTag(
					matches[ i ][ 1 ],
					matches[ i ][ 3 ],
					formulaField,
					formId
				)
			);
		}

		return this.replaceShorthandVariables( formula, formulaField, formId );
	};

	replaceMergeTag = (
		inputId: string,
		modifier: string = 'value',
		formulaField: any,
		formId: number
	) => {
		const fieldId = parseInt( inputId, 10 );

		if ( ! modifier ) {
			const isProductRadio = jQuery(
				'.gfield_price input[name=input_' + fieldId + ']'
			).is( 'input[type=radio]' );
			const isProductDropdown =
				jQuery( '.gfield_price select[name=input_' + fieldId + ']' )
					.length > 0;
			const isOptionCheckbox = jQuery(
				'.gfield_price input[name="input_' + inputId + '"]'
			).is( 'input[type=checkbox]' );

			if ( isProductDropdown || isProductRadio || isOptionCheckbox ) {
				modifier = 'price';
			}
		}

		const isVisible = window.gf_check_field_rule
			? window.gf_check_field_rule( formId, fieldId, true, '' ) === 'show'
			: true;

		let value = isVisible
			? window.GFMergeTag.getMergeTagValue( formId, inputId, modifier )
			: 0;

		if (
			/**
			 * Filter whether a number from a merge tag replacement should be cleaned (e.g. commas and currency removed).
			 *
			 * @since 1.0.2
			 *
			 * @param  boolean shouldClean 	Whether the number should be cleaned.
			 * @param  string  value 			The value from the merge tag replacement.
			 * @param  number  formId 			The current form ID.
			 * @param  number  fieldId 		The field that the value is being pulled from.
			 * @param  object  formulaField 	The current field having its formula evaluated. Contains properties such as `field_id`, `formula`, and `rounding`.
			 */
			window.gform.applyFilters(
				'gpac_should_clean_merge_tag_value',
				true,
				value,
				formId,
				fieldId,
				formulaField
			)
		) {
			value = window.gf_global.gfcalc[ formId ].cleanNumber(
				value,
				formId,
				fieldId,
				formulaField
			);
		}

		return value;
	};

	processExponents = (
		formula: string,
		formulaField: any,
		formId: number,
		calcObj: any
	) => {
		const items = this.parseFormula( formula ),
			processed: ParsedFormula = [];

		while ( items.length > 0 ) {
			const rootItem = items.shift()!;

			if ( rootItem.type !== 'operator' || rootItem.value !== '^' ) {
				processed.push( rootItem );
				continue;
			}

			const targetDepth = rootItem.depth;

			// get the base: dip back into the processed items and find all items that are at the same or greater depth than our target
			const base: ParsedFormula = [];
			while ( processed.length > 0 ) {
				const item = processed.pop()!;
				// accepts a single item at the same depth and only if an item of a greater depth has not already been added
				if ( item.depth === targetDepth ) {
					if ( base.length === 0 ) {
						base.push( item );
					} else {
						processed.push( item );
					}
					break;
					// accepts multiple items at a greater depth
				} else if ( item.depth > targetDepth ) {
					base.push( item );
				} else {
					processed.push( item );
					break;
				}
			}
			base.reverse();

			// get the power: only the first item at target depth or all items below target depth
			const pow: ParsedFormula = [];
			while ( items.length > 0 ) {
				const item = items.shift()!; // eslint-disable-line no-redeclare
				// accepts a single item at the same depth and only if an item of a greater depth has not already been added
				if ( item.depth === targetDepth ) {
					if ( pow.length === 0 ) {
						pow.push( item );
					} else {
						items.unshift( item );
					}
					break;
					// accepts multiple items at a greater depth
				} else if ( item.depth > targetDepth ) {
					pow.push( item );
					// if item is at a lesser depth, we're done collecting the pow. Add the current item back to the queue
					// so the outer while loop and loop for any other exponents.
				} else {
					items.unshift( item );
					break;
				}
			}

			const baseEvaled = this.evalFormula(
				this.processFunctions(
					calcObj.replaceFieldTags(
						formId,
						this.buildFormula( base ),
						formulaField
					),
					formulaField,
					formId,
					calcObj
				)
			);

			const powEvaled = this.evalFormula(
				this.processFunctions(
					calcObj.replaceFieldTags(
						formId,
						this.buildFormula( pow ),
						formulaField
					),
					formulaField,
					formId,
					calcObj
				)
			);

			processed.push( {
				...rootItem,
				type: 'value',
				value: Math.pow( baseEvaled, powEvaled ),
			} );
		}

		formula = this.buildFormula( processed );

		return formula;
	};

	evalFormula = ( formula: string ) => {
		let result = 0;

		try {
			formula = formula.trim();

			if ( formula.match( /^[0-9 +\-\/*()^><.=%&|!]+$/ ) ) {
				// eslint-disable-next-line no-eval
				result = eval( formula );
			}
		} catch ( e ) {
			// eslint-disable-next-line no-console
			console.warn(
				'GP Advanced Calculations: Could not process formula.',
				e
			);
		}

		return result;
	};

	processFunctions = (
		formula: string,
		formulaField: any,
		formId: number,
		calcObj: any
	) => {
		const items = this.parseFormula( formula ),
			processed: ParsedFormula = [],
			functions: {
				[ name: string ]: ( ...args: any[] ) => number;
			} = {
				abs: Math.abs,
				average() {
					return (
						Array.prototype.slice
							// eslint-disable-next-line prefer-rest-params
							.call( arguments )
							.reduce( function ( a, b ) {
								return a + b;
							}, 0 ) / arguments.length
					);
				},
				ceil: Math.ceil,
				exp: Math.exp,
				floor: Math.floor,
				fv(
					rate: number,
					numberOfPeriods: number,
					paymentAmount: number,
					presentValue: number,
					endOrBeginning: 0 | 1
				) {
					if ( typeof endOrBeginning === 'undefined' ) {
						endOrBeginning = 0;
					}

					// Credit https://stackoverflow.com/a/17195572
					const pow = Math.pow( 1 + rate, numberOfPeriods );
					let fv;

					if ( rate ) {
						// eslint-disable-next-line no-mixed-operators
						fv =
							( paymentAmount *
								( 1 + rate * endOrBeginning ) *
								( 1 - pow ) ) /
								rate -
							presentValue * pow;
					} else {
						// eslint-disable-next-line no-mixed-operators
						fv =
							-1 *
							( presentValue + paymentAmount * numberOfPeriods );
					}

					return fv;
				},
				ln: Math.log,
				log: Math.log10,
				max: Math.max,
				min: Math.min,
				pi: () => this.pi20DecimalPlaces,
				round: Math.round,
				sqrt: Math.sqrt,
			};

		while ( items.length > 0 ) {
			const rootItem = items.shift()!;

			if (
				rootItem.type !== 'function' ||
				typeof functions[ rootItem.value.toLowerCase() ] === 'undefined'
			) {
				processed.push( rootItem );
				continue;
			}

			const targetDepth = rootItem.depth;
			let contained = [];

			let item;

			while ( items.length > 0 ) {
				item = items.shift()!;

				if ( item.depth > targetDepth ) {
					contained.push( item );
				} else {
					break;
				}
			}

			contained = calcObj.replaceFieldTags(
				formId,
				this.buildFormula(
					JSON.parse( JSON.stringify( contained ) ), // Break reference to objects
					targetDepth + 1
				),
				formulaField
			);

			contained = contained.trim();

			// Replace inner functions with a placeholder, so they can be processed later
			const nestedFunctionPlaceholders: { [ key: string ]: any } = {};
			let nestedFunctionI = 0;

			const nestedFunctionRegex = /[a-z0-9]+\(([^()]+|[^(]+\([^)]*\)[^()]*)\)/gi;
			let m;

			while ( ( m = nestedFunctionRegex.exec( contained ) ) !== null ) {
				// This is necessary to avoid infinite loops with zero-width matches
				if ( m.index === nestedFunctionRegex.lastIndex ) {
					nestedFunctionRegex.lastIndex++;
				}

				// The result can be accessed through the `m`-variable.
				const match = m[ 0 ];
				const placeholder = 'NESTED_' + nestedFunctionI;
				nestedFunctionI++;

				nestedFunctionPlaceholders[ placeholder ] = match;

				contained = contained.replace( match, placeholder );
			}

			// eslint-disable-next-line no-shadow
			const args = contained
				.trim()
				.split( ',' )
				.map( ( item: string ) => {
					// Replace nested function placeholders back to the actual function call
					if ( item.indexOf( 'NESTED_' ) === 0 ) {
						item = nestedFunctionPlaceholders[ item ];
						item = this.processFunctions(
							item,
							formulaField,
							formId,
							calcObj
						);
					}

					return this.evalFormula( item );
				} );

			processed.push( {
				...rootItem,
				type: 'value',
				value: functions[ rootItem.value.toLowerCase() ].apply(
					null,
					args
				),
			} );

			if ( item && item.depth <= targetDepth ) {
				processed.push( item );
			}
		}

		formula = this.buildFormula( processed );

		return formula;
	};

	/**
	 * Moves the pointer forward when parsing the formula until the end of a specified pattern.
	 *
	 * char is always returned even if there's no match.
	 *
	 * @param  formula The formula being parsed.
	 * @param  char    The current character being read. Can be thought of as the pointer.
	 * @param  pattern The RegExp to find the end of the match.
	 */
	matchUntil = (
		formula: string,
		char: string,
		pattern: RegExp
	): { formula: string; value: string } => {
		let lastLetter: any = formula.match( pattern );
		let value: string;

		if ( lastLetter ) {
			lastLetter = lastLetter[ 0 ].length;
			value = char + formula.substr( 0, lastLetter );
			formula = formula.substr( lastLetter );
		} else {
			value = char;
		}

		return { formula, value };
	};

	parseFormula = ( formula: string ): ParsedFormula => {
		formula = formula.replace( '/s+/g', ' ' ).trim();

		const items: ParsedFormula = [];
		let depth = 0;

		while ( formula.length > 0 ) {
			const char = formula.substr( 0, 1 );

			let item: ParsedFormulaItem,
				value = '';

			let matchResult: { formula: string; value: string };

			formula = formula.substr( 1 );

			switch ( true ) {
				case char === '(':
					depth++;
					break;
				case char === ')':
					depth--;
					break;
				case jQuery.inArray( char, [
					'*',
					'-',
					'+',
					'/',
					'^',
					'%',
				] ) !== -1:
					item = {
						depth,
						type: 'operator',
						value: char as Operator,
					};
					break;
				case char === '{':
					const closingBrace = formula.indexOf( '}' );
					value = char + formula.substr( 0, closingBrace + 1 );
					formula = formula.substr( closingBrace + 1 );
					item = {
						depth,
						type: 'value',
						value,
					};
					break;
				case char === ',':
					item = {
						depth,
						type: 'sep',
						value: ',',
					};
					break;
				case jQuery.isNumeric( char ):
					matchResult = this.matchUntil( formula, char, /^[0-9.]+/ ); // Includes decimals; could be stricter.

					formula = matchResult.formula;
					value = matchResult.value;

					item = {
						depth,
						type: 'value',
						value,
					};
					break;
				case char.match( /[a-zA-Z]/i ) &&
					char.match( /[a-zA-Z]/i )!.length > 0:
					matchResult = this.matchUntil(
						formula,
						char,
						/^[a-zA-Z]+/i
					);

					formula = matchResult.formula;
					value = matchResult.value;

					item = {
						depth,
						type: 'function',
						value,
					};
					break;
				case ( char.match( /[<>!=]/ )?.length ?? 0 ) > 0:
					matchResult = this.matchUntil( formula, char, /^=+/ );

					formula = matchResult.formula;
					value = matchResult.value;

					item = {
						depth,
						type: 'comparisonOp',
						value: ' ' + value + ' ',
					};
					break;
				case ( char.match( /[&|]/ )?.length ?? 0 ) > 0:
					matchResult = this.matchUntil( formula, char, /^[&|]+/ );

					formula = matchResult.formula;
					value = matchResult.value;

					/* char is always returned in matchUntil(). We can't do anything with a single & or | so we throw it out. */
					if ( value.length === 1 ) {
						continue;
					}

					item = {
						depth,
						type: 'logicalOp',
						value: ' ' + value + ' ',
					};
					break;
			}

			if ( item! && item?.value ) {
				items.push( item );
			}
		}

		return items;
	};

	/**
	 * @param {ParsedFormula} parsedFormula
	 * @param {number}        startingDepth Specify the starting depth to avoid building a formula with extraneous parentheses
	 */
	buildFormula = ( parsedFormula: ParsedFormula, startingDepth = 0 ) => {
		let depth = 0,
			formula = '';

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

			/*
			 * Remove starting depth from the item. For instance, if we're taking only items from inside a
			 * function call, we give it the starting depth of the function to avoid building a formula that is wrapped
			 * in numerous parentheses.
			 */
			item.depth -= startingDepth;

			if ( depth < item.depth ) {
				formula += '('.repeat( item.depth - depth );
				depth = item.depth;
			} else if ( depth > item.depth ) {
				formula += ')'.repeat( depth - item.depth );
				depth = item.depth;
			}

			formula += item.value;
		}

		if ( depth > 0 ) {
			formula += ')'.repeat( depth );
		}

		return formula;
	};
}

window.GPAdvancedCalculations = GPAdvancedCalculations;

String.prototype.repeat = function ( num ) {
	// @ts-ignore
	return new Array( num + 1 ).join( this );
};
