<?php
/**
 * The Forminator_Calculator class.
 *
 * @package Forminator
 */

if ( ! defined( 'ABSPATH' ) ) {
	die();
}

/**********
 * Attempt to rewrite
 * https://github.com/chriskonnertz/string-calc
 * License (26-dec-2018) : MIT
 *
 * What changes :
 * - PHP 5.2 Compat
 * - Reduce/Remove extensibility, since it will managed by ours in the future
 * - Remove unnecessary abstract classes to reduce size
 * - wpcs compat
 * - use WP hook as extensibility alternatives
 */


require_once __DIR__ . '/class-exception.php';

// tokenizer.
require_once __DIR__ . '/parser/class-token.php';
require_once __DIR__ . '/parser/class-tokenizer.php';

// load symbols.
require_once __DIR__ . '/symbol/class-loader.php';

// load nodes.
require_once __DIR__ . '/parser/node/abstract-class.php';
require_once __DIR__ . '/parser/node/class-container.php';
require_once __DIR__ . '/parser/node/class-function.php';
require_once __DIR__ . '/parser/node/class-symbol.php';

// parser.
require_once __DIR__ . '/parser/class-parser.php';

/**
 * Class Forminator_Calculator
 *
 * @since 1.7
 */
class Forminator_Calculator {

	/**
	 * Term
	 *
	 * @var string
	 */
	protected $term;

	/**
	 * Tokenizer
	 *
	 * @var Forminator_Calculator_Parser_Tokenizer
	 */
	protected $tokenizer;

	/**
	 * Symbol loader
	 *
	 * @var Forminator_Calculator_Symbol_Loader
	 */
	protected $symbol_loader;

	/**
	 * Throwable
	 *
	 * @var bool
	 */
	protected $is_throwable = false;

	/**
	 * Parser
	 *
	 * @var Forminator_Calculator_Parser
	 */
	protected $parser;

	/**
	 * Forminator_Calculator constructor
	 *
	 * @param mixed $term Term.
	 */
	public function __construct( $term ) {
		$this->term = $term;

		$this->setup_tokenizer();

		$this->symbol_loader = new Forminator_Calculator_Symbol_Loader();

		$this->setup_parser();

		$this->prepare_term();
	}

	/**
	 * Setup tokenizer
	 *
	 * @uses apply_filters()
	 */
	public function setup_tokenizer() {
		$term      = $this->term;
		$tokenizer = new Forminator_Calculator_Parser_Tokenizer( $term );

		/**
		 * Filter tokenizer to be used on calculator
		 *
		 * @since 1.7
		 *
		 * @param Forminator_Calculator_Parser_Tokenizer $tokenizer Tokenizer.
		 * @param string                                 $term Term.
		 *
		 * @return Forminator_Calculator_Parser_Tokenizer
		 */
		$tokenizer = apply_filters( 'forminator_calculator_tokenizer', $tokenizer, $term );

		$this->tokenizer = $tokenizer;
	}

	/**
	 * Setup Parser
	 *
	 * @uses apply_filters()
	 */
	public function setup_parser() {
		$term          = $this->term;
		$symbol_loader = $this->symbol_loader;
		$parser        = new Forminator_Calculator_Parser( $symbol_loader );

		/**
		 * Filter parser to be used on calculator
		 *
		 * @since 1.7
		 *
		 * @param Forminator_Calculator_Parser        $parser
		 * @param Forminator_Calculator_Symbol_Loader $symbol_loader
		 * @param string                              $term
		 *
		 * @return Forminator_Calculator_Parser
		 */
		$parser = apply_filters( 'forminator_calculator_tokenizer', $parser, $symbol_loader, $term );

		$this->parser = $parser;
	}

	/**
	 * Prepare term
	 *
	 * @return void
	 */
	public function prepare_term() {
		$term = $this->term;

		/**
		 * Filter term that will be parsed by calculator
		 *
		 * @since 1.7
		 *
		 * @param string $term
		 *
		 * @return string
		 */
		$term = apply_filters( 'forminator_calculator_prepare_term', $term );

		$this->term = $term;
	}

	/**
	 * Parse
	 *
	 * @return Forminator_Calculator_Parser_Node_Container|boolean
	 * @throws Forminator_Calculator_Exception When there is an Calculator error.
	 */
	public function parse() {
		try {
			// reset.
			$this->tokenizer->input = $this->term;
			$this->tokenizer->reset();

			$tokens = $this->tokenizer->tokenize();
			if ( count( $tokens ) === 0 ) {
				throw new Forminator_Calculator_Exception( 'Error: Empty token of calculator term.' );
			}

			$root_node = $this->parser->parse( $tokens );
			if ( $root_node->is_empty() ) {
				throw new Forminator_Calculator_Exception( 'Error: Empty nodes of calculator tokens.' );
			}

			return $root_node;
		} catch ( Forminator_Calculator_Exception $e ) {
			// suppress.
			forminator_maybe_log( __METHOD__, $e->getMessage(), $e->getTrace() );

			if ( $this->is_throwable ) {
				throw $e;
			}
		}

		return false;
	}

	/**
	 * Calculate
	 *
	 * @return float|int
	 * @throws Forminator_Calculator_Exception When there is an Calculator error.
	 */
	public function calculate() {
		$result    = 0;
		$root_node = $this->parse();

		if ( false === $root_node ) {
			return $result;
		}

		try {
			$result = $this->calculate_node( $root_node );
		} catch ( Forminator_Calculator_Exception $e ) {
			// suppress.
			forminator_maybe_log( __METHOD__, $e->getMessage(), $e->getTrace() );

			if ( $this->is_throwable ) {
				throw $e;
			}
		}

		return $result;
	}

	/**
	 * Calculates the numeric value / result of a node of
	 * any known and calculable type. (For example symbol
	 * nodes with a symbol of type separator are not
	 * calculable.)
	 *
	 * @param Forminator_Calculator_Parser_Node_Abstract $node Forminator_Calculator_Parser_Node_Abstract.
	 *
	 * @return float|int
	 * @throws Forminator_Calculator_Exception When there is an Calculator error.
	 */
	protected function calculate_node( $node ) {
		if ( $node instanceof Forminator_Calculator_Parser_Node_Symbol ) {
			/**
			 * Forminator_Calculator_Parser_Node_Symbol
			 *
			 * @var Forminator_Calculator_Parser_Node_Symbol $node */

			return $this->calculate_symbol_node( $node );
		} elseif ( $node instanceof Forminator_Calculator_Parser_Node_Function ) {
			/**
			 * Forminator_Calculator_Parser_Node_Function
			 *
			 * @var Forminator_Calculator_Parser_Node_Function $node */

			return $this->calculate_function_node( $node );
		} elseif ( $node instanceof Forminator_Calculator_Parser_Node_Container ) {
			/**
			 * Forminator_Calculator_Parser_Node_Container
			 *
			 * @var Forminator_Calculator_Parser_Node_Container $node */

			return $this->calculate_container_node( $node );
		} else {
			throw new Forminator_Calculator_Exception( 'Error: Cannot calculate node of unknown type "' . esc_html( get_class( $node ) ) . '"' );// @codeCoverageIgnore.
		}
	}

	/**
	 * This method actually calculates the results of every sub-terms
	 * in the syntax tree (which consists of nodes).
	 * It can call itself recursively.
	 * Attention: $node must not be of type FunctionNode!
	 *
	 * @param Forminator_Calculator_Parser_Node_Container $container_node Forminator_Calculator_Parser_Node_Container.
	 *
	 * @return float|int
	 * @throws Forminator_Calculator_Exception When there is an Calculator error.
	 */
	protected function calculate_container_node( $container_node ) {
		if ( $container_node instanceof Forminator_Calculator_Parser_Node_Function ) {
			throw new Forminator_Calculator_Exception( 'Error: Expected container node but got a function node' ); // @codeCoverageIgnore.
		}

		$nodes = $container_node->get_child_nodes();

		$ordered_operator_nodes = $this->detect_calculation_order( $nodes );

		// Actually calculate the term. Iterates over the ordered operators and.
		// calculates them, then replaces the parts of the operation by the result.
		foreach ( $ordered_operator_nodes as $index => $operator_node ) {
			reset( $nodes );
			while ( key( $nodes ) !== $index ) {
				$left_operand       = current( $nodes );
				$left_operand_index = key( $nodes );
				next( $nodes ); // back to operator cursor.
			}

			$right_operand       = next( $nodes );
			$right_operand_index = key( $nodes );
			$right_number        = is_numeric( $right_operand ) ? $right_operand : $this->calculate_node( $right_operand );

			/**
			 * Forminator_Calculator_Symbol_Operator_Abstract
			 *
			 * @var Forminator_Calculator_Symbol_Operator_Abstract $symbol */
			$symbol = $operator_node->get_symbol();

			if ( $operator_node->is_unary_operator() ) {
				$result = $symbol->operate( null, $right_number );

				// Replace the participating symbols of the operation by the result.
				unset( $nodes[ $right_operand_index ] );
				$nodes[ $index ] = $result;
			} elseif ( isset( $left_operand_index ) && isset( $left_operand ) ) {
					$left_number = is_numeric( $left_operand ) ? $left_operand : $this->calculate_node( $left_operand );

					$result = $symbol->operate( $left_number, $right_number );

					// Replace the participating symbols of the operation by the result.
					unset( $nodes[ $left_operand_index ] );
					unset( $nodes[ $right_operand_index ] );
					$nodes[ $index ] = $result;
			}
		}

		if ( count( $nodes ) === 0 ) {
			throw new Forminator_Calculator_Exception( 'Error: Missing calculable subterm. Are there empty brackets?' );
		}

		if ( count( $nodes ) > 1 ) {
			throw new Forminator_Calculator_Exception( 'Error: Missing operators between parts of the term.' );
		}

		// The only remaining element of the $nodes array contains the overall result.
		$result = end( $nodes );

		// If the $nodes array did not contain any operator (but only one node) than.
		// the result of this node has to be calculated now.
		if ( ! is_numeric( $result ) ) {
			return $this->calculate_node( $result );
		}

		return $result;
	}

	/**
	 * Returns the numeric value of a function node.
	 *
	 * @param Forminator_Calculator_Parser_Node_Function $function_node Forminator_Calculator_Parser_Node_Function.
	 *
	 * @return int|float
	 */
	protected function calculate_function_node( $function_node ) {
		$nodes = $function_node->get_child_nodes();

		$arguments            = array(); // ex : func(1+2,3,4) : 1+2 need to be calculated first.
		$argument_child_nodes = array();

		foreach ( $nodes as $node ) {
			if ( $node instanceof Forminator_Calculator_Parser_Node_Symbol ) {
				/**
				 * Forminator_Calculator_Parser_Node_Symbol
				 *
				 * @var Forminator_Calculator_Parser_Node_Symbol $node */

				if ( $node->get_symbol() instanceof Forminator_Calculator_Symbol_Separator ) {
					$container_node       = new Forminator_Calculator_Parser_Node_Container( $argument_child_nodes );
					$arguments[]          = $this->calculate_container_node( $container_node );
					$argument_child_nodes = array();
				} else {
					$argument_child_nodes[] = $node;
				}
			} else {
				$argument_child_nodes[] = $node;
			}
		}

		if ( count( $argument_child_nodes ) > 0 ) {
			$container_node = new Forminator_Calculator_Parser_Node_Container( $argument_child_nodes );
			$arguments[]    = $this->calculate_container_node( $container_node );
		}

		/**
		 * Forminator_Calculator_Symbol_Function_Abstract
		 *
		 * @var Forminator_Calculator_Symbol_Function_Abstract $symbol */
		$symbol = $function_node->get_symbol_node()->get_symbol();

		$result = $symbol->execute( $arguments );

		return $result;
	}

	/**
	 * Returns the numeric value of a symbol node.
	 * Attention: $node->symbol must not be of type AbstractOperator!
	 *
	 * @param Forminator_Calculator_Parser_Node_Symbol $symbol_node Node symbol.
	 *
	 * @return int|float
	 * @throws Forminator_Calculator_Exception When there is an Calculator error.
	 */
	protected function calculate_symbol_node( $symbol_node ) {
		$symbol = $symbol_node->get_symbol();

		if ( $symbol instanceof Forminator_Calculator_Symbol_Number ) {
			$number = $symbol_node->get_token()->value;

			// Convert string to int or float (depending on the type of the number).
			// Attention: The fractional part of a PHP float can only have a limited length.
			// If the number has a longer fractional part, it will be cut.
			$number = 0 + $number;
		} elseif ( $symbol instanceof Forminator_Calculator_Symbol_Constant_Abstract ) {
			/**
			 * Forminator_Calculator_Symbol_Constant_Abstract
			 *
			 * @var Forminator_Calculator_Symbol_Constant_Abstract $symbol */

			$number = $symbol->get_value();
		} else {
			throw new Forminator_Calculator_Exception( 'Error: Found symbol of unexpected type "' . esc_html( get_class( $symbol ) ) . '", expected number or constant' );
		}

		return $number;
	}

	/**
	 * Detect the calculation order of a given array of nodes.
	 * Does only care for the precedence of operators.
	 * Does not care for child nodes of container nodes.
	 * Returns a new array with ordered symbol nodes.
	 *
	 * @param Forminator_Calculator_Parser_Node_Abstract[] $nodes Forminator_Calculator_Parser_Node_Abstract.
	 *
	 * @return Forminator_Calculator_Parser_Node_Symbol[]
	 */
	protected function detect_calculation_order( $nodes ) {
		$operator_nodes = array();

		// Store all symbol nodes that have a symbol of type abstract operator in an array.
		foreach ( $nodes as $index => $node ) {
			if ( $node instanceof Forminator_Calculator_Parser_Node_Symbol ) {
				if ( $node->get_symbol() instanceof Forminator_Calculator_Symbol_Operator_Abstract ) {
					$operator_nodes[ $index ] = $node;
				}
			}
		}

		// Using Quick-sort algorithm to sort the operators according to their precedence. Keeps the indices.
		uasort( $operator_nodes, array( $this, 'sort_operator_precedence' ) );

		return $operator_nodes;
	}

	/**
	 *
	 * Returning 1 means $nodeTwo before $nodeOne, returning -1 means $nodeOne before $nodeTwo.
	 *
	 * @param Forminator_Calculator_Parser_Node_Symbol $node_one Forminator_Calculator_Parser_Node_Symbol.
	 * @param Forminator_Calculator_Parser_Node_Symbol $node_two Forminator_Calculator_Parser_Node_Symbol.
	 *
	 * @return int
	 */
	private function sort_operator_precedence( $node_one, $node_two ) {

		// First-level precedence of node one.
		/**
		 * Forminator_Calculator_Symbol_Operator_Abstract
		 *
		 * @var Forminator_Calculator_Symbol_Operator_Abstract $symbol_one */
		$symbol_one     = $node_one->get_symbol();
		$precedence_one = 2;
		if ( $node_one->is_unary_operator() ) {
			$precedence_one = 3;
		}

		// First-level precedence of node two.
		/**
		 * Forminator_Calculator_Symbol_Operator_Abstract
		 *
		 * @var Forminator_Calculator_Symbol_Operator_Abstract $symbol_two */
		$symbol_two     = $node_two->get_symbol();
		$precedence_two = 2;
		if ( $node_two->is_unary_operator() ) {
			$precedence_two = 3;
		}

		// If the first-level precedence is the same, compare the second-level precedence.
		if ( $precedence_one === $precedence_two ) {
			$precedence_one = $symbol_one->get_precedence();
			$precedence_two = $symbol_two->get_precedence();
		}

		// If the second-level precedence is the same, we have to ensure that the sorting algorithm does.
		// insert the node / token that is left in the term before the node / token that is right.
		// Therefore we cannot return 0 but compare the positions and return 1 / -1.
		if ( $precedence_one === $precedence_two ) {
			return ( $node_one->get_token()->position < $node_two->get_token()->position ) ? - 1 : 1;
		}

		return ( $precedence_one < $precedence_two ) ? 1 : - 1;
	}

	/**
	 * Set is throwable
	 *
	 * @param true $is_throwable Is throwable?.
	 */
	public function set_is_throwable( $is_throwable ) {
		$this->is_throwable = $is_throwable;
	}
}
