<?php declare(strict_types = 0);
/*
** Copyright (C) 2001-2026 Zabbix SIA
**
** This program is free software: you can redistribute it and/or modify it under the terms of
** the GNU Affero General Public License as published by the Free Software Foundation, version 3.
**
** This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
** without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
** See the GNU Affero General Public License for more details.
**
** You should have received a copy of the GNU Affero General Public License along with this program.
** If not, see <https://www.gnu.org/licenses/>.
**/


class CExpressionParser extends CParser {

	// For parsing of expressions.
	private const STATE_AFTER_OPEN_BRACE = 1;
	private const STATE_AFTER_BINARY_OPERATOR = 2;
	private const STATE_AFTER_LOGICAL_OPERATOR = 3;
	private const STATE_AFTER_NOT_OPERATOR = 4;
	private const STATE_AFTER_UNARY_MINUS = 5;
	private const STATE_AFTER_CLOSE_BRACE = 6;
	private const STATE_AFTER_CONSTANT = 7;

	// For parsing of math function parameters.
	private const STATE_NEW = 1;
	private const STATE_END = 2;
	private const STATE_END_OF_PARAMS = 3;

	private const MAX_MATH_FUNCTION_DEPTH = 32;

	/**
	 * An error message if trigger expression is not valid
	 *
	 * @var string
	 */
	private $error = '';

	/**
	 * An options array.
	 *
	 * Supported options:
	 *   'usermacros' => false            Enable user macros usage in expression.
	 *   'lldmacros' => false             Enable low-level discovery macros usage in expression.
	 *   'collapsed_expression' => false  Short trigger expression.
	 *                                       For example: {439} > {$MAX_THRESHOLD} or {439} < {$MIN_THRESHOLD}
	 *   'calculated' => false            Parse calculated item formula instead of trigger expression.
	 *   'host_macro' => false            Allow {HOST.HOST} macro as host name part in the query.
	 *   'host_macro_n' => false          Allow {HOST.HOST} and {HOST.HOST<1-9>} macros as host name part in the query.
	 *   'empty_host' => false            Allow empty hostname in the query string.
	 *   'escape_backslashes' => true     Disable backslash escaping in history function parameters prior to v7.0.
	 *
	 * @var array
	 */
	private $options = [
		'usermacros' => false,
		'lldmacros' => false,
		'collapsed_expression' => false,
		'calculated' => false,
		'host_macro' => false,
		'host_macro_n' => false,
		'empty_host' => false,
		'escape_backslashes' => true
	];

	/**
	 * Object containing the results of parsing.
	 *
	 * @var null|CExpressionParserResult
	 */
	private $result;

	/**
	 * Chars that should be treated as spaces.
	 */
	public const WHITESPACES = " \r\n\t";

	/**
	 * @param array $options
	 */
	public function __construct(array $options = []) {
		$this->options = $options + $this->options;

		if ($this->options['collapsed_expression']
				&& ($this->options['host_macro'] || $this->options['host_macro_n'])) {
			exit('Incompatible options.');
		}
	}

	/**
	 * Parse an expression and set public variables $this->error, $this->result
	 *
	 * Examples:
	 *   last(/Zabbix server/agent.ping,0) = 1 and {TRIGGER.VALUE} = {$MACRO}
	 *
	 * @param string $source
	 * @param int    $pos
	 *
	 * @return int
	 */
	public function parse($source, $pos = 0) {
		// initializing local variables
		$this->error = '';
		$this->match = '';
		$this->length = 0;

		$p = $pos;
		$tokens = [];
		$parsed_pos = 0;

		if (self::parseExpression($source, $p, $tokens, $this->options, $parsed_pos)) {
			// Including trailing whitespaces as part of the expression.
			if (preg_match('/^['.self::WHITESPACES.']+$/', substr($source, $p), $matches)) {
				$p += strlen($matches[0]);
			}
			$len = $p - $pos;

			$this->length = $len;
			$this->match = substr($source, $pos, $len);

			$this->result = new CExpressionParserResult();
			$this->result->addTokens($tokens);
			$this->result->pos = $pos;

			if (isset($source[$p])) {
				$this->error = _s('incorrect expression starting from "%1$s"', substr($source, $parsed_pos));

				return self::PARSE_SUCCESS_CONT;
			}

			return self::PARSE_SUCCESS;
		}

		$this->error = _s('incorrect expression starting from "%1$s"', substr($source, $parsed_pos));

		return self::PARSE_FAIL;
	}

	/**
	 * Parses an expression.
	 *
	 * @param string  $source
	 * @param int     $pos
	 * @param array   $tokens
	 * @param array   $options
	 * @param int     $parsed_pos
	 * @param int     $depth
	 *
	 * @return bool  Returns true if parsed successfully, false otherwise.
	 */
	private static function parseExpression(string $source, int &$pos, array &$tokens, array $options,
			?int &$parsed_pos = null, int $depth = 0): bool {
		$binary_operator_parser = new CSetParser(['<', '>', '<=', '>=', '+', '-', '/', '*', '=', '<>']);
		$logical_operator_parser = new CSetParser(['and', 'or']);

		if ($depth++ > self::MAX_MATH_FUNCTION_DEPTH) {
			return false;
		}

		$state = self::STATE_AFTER_OPEN_BRACE;
		$after_space = false;
		$level = 0;
		$p = $pos;
		$_tokens = [];

		while (isset($source[$p])) {
			$char = $source[$p];

			if (strpos(self::WHITESPACES, $char) !== false) {
				$after_space = true;
				$p++;
				continue;
			}

			switch ($state) {
				case self::STATE_AFTER_OPEN_BRACE:
					switch ($char) {
						case '-':
							$state = self::STATE_AFTER_UNARY_MINUS;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_OPERATOR,
								'pos' => $p,
								'match' => $char,
								'length' => 1
							];
							break;

						case '(':
							$level++;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_OPEN_BRACE,
								'pos' => $p,
								'match' => $char,
								'length' => 1
							];
							break;

						default:
							if (self::parseNot($source, $p, $_tokens)) {
								$state = self::STATE_AFTER_NOT_OPERATOR;
							}
							elseif (self::parseConstant($source, $p, $_tokens, $options, $depth)) {
								$state = self::STATE_AFTER_CONSTANT;

								if ($level == 0) {
									$pos = $p + 1;
									$tokens = $_tokens;
								}
							}
							else {
								break 3;
							}
					}
					break;

				case self::STATE_AFTER_BINARY_OPERATOR:
					switch ($char) {
						case '-':
							$state = self::STATE_AFTER_UNARY_MINUS;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_OPERATOR,
								'pos' => $p,
								'match' => $char,
								'length' => 1
							];
							break;

						case '(':
							$level++;
							$state = self::STATE_AFTER_OPEN_BRACE;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_OPEN_BRACE,
								'pos' => $p,
								'match' => $char,
								'length' => 1
							];
							break;

						default:
							if (self::parseConstant($source, $p, $_tokens, $options, $depth)) {
								$state = self::STATE_AFTER_CONSTANT;

								if ($level == 0) {
									$pos = $p + 1;
									$tokens = $_tokens;
								}
								break;
							}

							if ($after_space && self::parseNot($source, $p, $_tokens)) {
								$state = self::STATE_AFTER_NOT_OPERATOR;
							}
							else {
								break 3;
							}
					}
					break;

				case self::STATE_AFTER_LOGICAL_OPERATOR:
					switch ($char) {
						case '-':
							if (!$after_space) {
								break 3;
							}
							$state = self::STATE_AFTER_UNARY_MINUS;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_OPERATOR,
								'pos' => $p,
								'match' => $char,
								'length' => 1
							];
							break;

						case '(':
							$level++;
							$state = self::STATE_AFTER_OPEN_BRACE;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_OPEN_BRACE,
								'pos' => $p,
								'match' => $char,
								'length' => 1
							];
							break;

						default:
							if (!$after_space) {
								break 3;
							}

							if (self::parseNot($source, $p, $_tokens)) {
								$state = self::STATE_AFTER_NOT_OPERATOR;
							}
							elseif (self::parseConstant($source, $p, $_tokens, $options, $depth)) {
								$state = self::STATE_AFTER_CONSTANT;

								if ($level == 0) {
									$pos = $p + 1;
									$tokens = $_tokens;
								}
							}
							else {
								break 3;
							}
					}
					break;

				case self::STATE_AFTER_CLOSE_BRACE:
					switch ($char) {
						case ')':
							if ($level == 0) {
								break 3;
							}
							$level--;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_CLOSE_BRACE,
								'pos' => $p,
								'match' => $char,
								'length' => 1
							];

							if ($level == 0) {
								$pos = $p + 1;
								$tokens = $_tokens;
							}
							break;

						default:
							if (self::parseUsing($binary_operator_parser, $source, $p, $_tokens,
									CExpressionParserResult::TOKEN_TYPE_OPERATOR)) {
								$state = self::STATE_AFTER_BINARY_OPERATOR;
								break;
							}

							if (self::parseUsing($logical_operator_parser, $source, $p, $_tokens,
									CExpressionParserResult::TOKEN_TYPE_OPERATOR)) {
								$state = self::STATE_AFTER_LOGICAL_OPERATOR;
								break;
							}
							break 3;
					}
					break;

				case self::STATE_AFTER_CONSTANT:
					switch ($char) {
						case ')':
							if ($level == 0) {
								break 3;
							}
							$level--;
							$state = self::STATE_AFTER_CLOSE_BRACE;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_CLOSE_BRACE,
								'pos' => $p,
								'match' => $char,
								'length' => 1
							];

							if ($level == 0) {
								$pos = $p + 1;
								$tokens = $_tokens;
							}
							break;

						default:
							if (self::parseUsing($binary_operator_parser, $source, $p, $_tokens,
									CExpressionParserResult::TOKEN_TYPE_OPERATOR)) {
								$state = self::STATE_AFTER_BINARY_OPERATOR;
								break;
							}

							if ($after_space && self::parseUsing($logical_operator_parser, $source, $p, $_tokens,
									CExpressionParserResult::TOKEN_TYPE_OPERATOR)) {
								$state = self::STATE_AFTER_LOGICAL_OPERATOR;
							}
							else {
								break 3;
							}
					}
					break;

				case self::STATE_AFTER_NOT_OPERATOR:
					switch ($char) {
						case '-':
							if (!$after_space) {
								break 3;
							}
							$state = self::STATE_AFTER_UNARY_MINUS;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_OPERATOR,
								'pos' => $p,
								'match' => $char,
								'length' => 1
							];
							break;

						case '(':
							$level++;
							$state = self::STATE_AFTER_OPEN_BRACE;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_OPEN_BRACE,
								'pos' => $p,
								'match' => $char,
								'length' => 1
							];
							break;

						default:
							if (!$after_space) {
								break 3;
							}

							if (self::parseConstant($source, $p, $_tokens, $options, $depth)) {
								$state = self::STATE_AFTER_CONSTANT;

								if ($level == 0) {
									$pos = $p + 1;
									$tokens = $_tokens;
								}
							}
							else {
								break 3;
							}
					}
					break;

				case self::STATE_AFTER_UNARY_MINUS:
					switch ($char) {
						case '(':
							$level++;
							$state = self::STATE_AFTER_OPEN_BRACE;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_OPEN_BRACE,
								'pos' => $p,
								'match' => $char,
								'length' => 1
							];
							break;

						default:
							if (self::parseConstant($source, $p, $_tokens, $options, $depth)) {
								$state = self::STATE_AFTER_CONSTANT;

								if ($level == 0) {
									$pos = $p + 1;
									$tokens = $_tokens;
								}
							}
							else {
								break 3;
							}
					}
					break;
			}

			$after_space = false;
			$p++;
		}

		$parsed_pos = $p;

		return (bool) $tokens;
	}

	/**
	 * Parse unary "not".
	 *
	 * @param string  $source
	 * @param int     $pos
	 * @param array   $tokens
	 *
	 * @return bool
	 */
	private static function parseNot(string $source, int &$pos, array &$tokens): bool {
		if (substr($source, $pos, 3) !== 'not' || !isset($source[$pos + 3])
				|| strpos(self::WHITESPACES.'(', $source[$pos + 3]) === false) {
			return false;
		}

		$tokens[] = [
			'type' => CExpressionParserResult::TOKEN_TYPE_OPERATOR,
			'pos' => $pos,
			'match' => 'not',
			'length' => 3
		];
		$pos += 2;

		return true;
	}

	/**
	 * Parse the string using the given parser. If a match has been found, move the cursor to the last symbol of the
	 * matched string.
	 *
	 * @param CParser $parser
	 * @param string  $source
	 * @param int     $pos
	 * @param array   $tokens
	 * @param int     $token_type
	 *
	 * @return bool
	 */
	private static function parseUsing(CParser $parser, string $source, int &$pos, array &$tokens,
			int $token_type): bool {
		if ($parser->parse($source, $pos) == CParser::PARSE_FAIL) {
			return false;
		}

		$tokens[] = [
			'type' => $token_type,
			'pos' => $pos,
			'match' => $parser->getMatch(),
			'length' => $parser->getLength()
		];
		$pos += $parser->getLength() - 1;

		return true;
	}

	/**
	 * Parses a constant in the expression.
	 *
	 * The constant can be (depending on options):
	 *  - function like func(<expression>)
	 *  - function like func(/host/item,<params>)
	 *  - floating point number; can be with suffix [KMGTsmhdw]
	 *  - string
	 *  - macros like {TRIGGER.VALUE} and {{TRIGGER.VALUE}.func()}
	 *  - user macros like {$MACRO} and {{$MACRO}.func()}
	 *  - LLD macros like {#LLD} and {{#LLD}.func()}
	 *
	 * @param string  $source
	 * @param int     $pos
	 * @param array   $tokens
	 * @param array   $options
	 * @param int     $depth
	 *
	 * @return bool  Returns true if parsed successfully, false otherwise.
	 */
	private static function parseConstant(string $source, int &$pos, array &$tokens, array $options, int $depth): bool {
		if (self::parseNumber($source, $pos, $tokens) || self::parseString($source, $pos, $tokens)) {
			return true;
		}

		if (!$options['calculated']) {
			if (self::parseUsing(new CMacroParser(['macros' => ['{TRIGGER.VALUE}']]), $source, $pos, $tokens,
					CExpressionParserResult::TOKEN_TYPE_MACRO)) {
				return true;
			}

			if (self::parseUsing(new CMacroFunctionParser(['macros' => ['{TRIGGER.VALUE}']]), $source, $pos, $tokens,
					CExpressionParserResult::TOKEN_TYPE_MACRO)) {
				return true;
			}
		}

		if ($options['collapsed_expression']) {
			if (self::parseUsing(new CFunctionIdParser, $source, $pos, $tokens,
					CExpressionParserResult::TOKEN_TYPE_FUNCTIONID_MACRO)) {
				return true;
			}
		}
		elseif (self::parseHistFunction($source, $pos, $tokens, $options)) {
			return true;
		}

		if (self::parseMathFunction($source, $pos, $tokens, $options, $depth)) {
			return true;
		}

		if ($options['usermacros']) {
			if (self::parseUsing(new CUserMacroParser, $source, $pos, $tokens,
					CExpressionParserResult::TOKEN_TYPE_USER_MACRO)) {
				return true;
			}

			if (self::parseUsing(new CUserMacroFunctionParser, $source, $pos, $tokens,
					CExpressionParserResult::TOKEN_TYPE_USER_MACRO)) {
				return true;
			}
		}

		if ($options['lldmacros']) {
			if (self::parseUsing(new CLLDMacroParser, $source, $pos, $tokens,
					CExpressionParserResult::TOKEN_TYPE_LLD_MACRO)) {
				return true;
			}

			if (self::parseUsing(new CLLDMacroFunctionParser, $source, $pos, $tokens,
					CExpressionParserResult::TOKEN_TYPE_LLD_MACRO)) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Parses a historical function constant in the expression.
	 *
	 * @param string  $source
	 * @param int     $pos
	 * @param array   $tokens
	 * @param array   $options
	 *
	 * @return bool  Returns true if parsed successfully, false otherwise.
	 */
	private static function parseHistFunction(string $source, int &$pos, array &$tokens, array $options): bool {
		$hist_function_parser = new CHistFunctionParser([
			'usermacros' => $options['usermacros'],
			'lldmacros' => $options['lldmacros'],
			'calculated' => $options['calculated'],
			'host_macro' => $options['host_macro'],
			'host_macro_n' => $options['host_macro_n'],
			'empty_host' => $options['empty_host'],
			'escape_backslashes' => $options['escape_backslashes']
		]);

		if ($hist_function_parser->parse($source, $pos) == CParser::PARSE_FAIL) {
			return false;
		}

		$len = $hist_function_parser->getLength();
		$tokens[] = [
			'type' => CExpressionParserResult::TOKEN_TYPE_HIST_FUNCTION,
			'pos' => $pos,
			'match' => $hist_function_parser->getMatch(),
			'length' => $len,
			'data' => [
				'function' => $hist_function_parser->getFunction(),
				'parameters' => $hist_function_parser->getParameters()
			]
		];
		$pos += $len - 1;

		return true;
	}

	/**
	 * Parses a math function constant in the expression.
	 *
	 * @param string  $source
	 * @param int     $pos
	 * @param array   $tokens
	 * @param array   $options
	 * @param int     $depth
	 *
	 * @return bool  Returns true if parsed successfully, false otherwise.
	 */
	private static function parseMathFunction(string $source, int &$pos, array &$tokens, array $options,
			int $depth): bool {
		$p = $pos;

		if (!preg_match('/^([a-z0-9_]+)\(/', substr($source, $p), $matches)) {
			return false;
		}

		$p += strlen($matches[0]);
		$p2 = $p - 1;
		$_tokens = [];
		$state = self::STATE_NEW;

		while (isset($source[$p])) {
			switch ($state) {
				case self::STATE_NEW:
					switch ($source[$p]) {
						case ' ':
							break;

						case ')':
							if (!$_tokens) {
								$state = self::STATE_END_OF_PARAMS;
								break;
							}
							break 3;

						default:
							$_p = $p;
							$expression_tokens = [];
							$parsed_pos = 0;

							if (!self::parseExpression($source, $_p, $expression_tokens, $options, $parsed_pos,
									$depth)) {
								break 3;
							}

							$len = $_p - $p;
							$_tokens[] = [
								'type' => CExpressionParserResult::TOKEN_TYPE_EXPRESSION,
								'pos' => $p,
								'match' => substr($source, $p, $len),
								'length' => $len,
								'data' => [
									'tokens' => $expression_tokens
								]
							];
							$p = $_p - 1;
							$state = self::STATE_END;
					}
					break;

				case self::STATE_END:
					switch ($source[$p]) {
						case ' ':
							break;

						case ')':
							$state = self::STATE_END_OF_PARAMS;
							break;

						case ',':
							$state = self::STATE_NEW;
							break;

						default:
							break 3;
					}
					break;

				case self::STATE_END_OF_PARAMS:
					break 2;
			}

			$p++;
		}

		if ($state != self::STATE_END_OF_PARAMS) {
			return false;
		}

		$len = $p - $pos;
		$tokens[] = [
			'type' => CExpressionParserResult::TOKEN_TYPE_MATH_FUNCTION,
			'pos' => $pos,
			'match' => substr($source, $pos, $len),
			'length' => $len,
			'data' => [
				'function' => $matches[1],
				'parameters' => $_tokens
			]
		];
		$pos += $len - 1;

		return true;
	}

	/**
	 * Parses a number constant in the expression and moves a current position on a last symbol of the number.
	 *
	 * @param string  $source
	 * @param int     $pos
	 * @param array   $tokens
	 *
	 * @return bool returns true if parsed successfully, false otherwise
	 */
	private static function parseNumber(string $source, int &$pos, array &$tokens): bool {
		$number_parser = new CNumberParser([
			'with_minus' => false,
			'with_size_suffix' => true,
			'with_time_suffix' => true
		]);

		if ($number_parser->parse($source, $pos) == CParser::PARSE_FAIL) {
			return false;
		}

		$value = $number_parser->calcValue();
		if (abs($value) == INF) {
			return false;
		}

		$len = $number_parser->getLength();
		$tokens[] = [
			'type' => CExpressionParserResult::TOKEN_TYPE_NUMBER,
			'pos' => $pos,
			'match' => $number_parser->getMatch(),
			'length' => $len,
			'data' => ['suffix' => $number_parser->getSuffix()]
		];
		$pos += $len - 1;

		return true;
	}

	/**
	 * Parses a quoted string constant in the expression.
	 *
	 * @param string  $source
	 * @param int     $pos
	 * @param array   $tokens
	 *
	 * @return bool returns true if parsed successfully, false otherwise
	 */
	private static function parseString(string $source, int &$pos, array &$tokens): bool {
		if (!preg_match('/^"([^"\\\\]|\\\\["\\\\])*"/', substr($source, $pos), $matches)) {
			return false;
		}

		$len = strlen($matches[0]);
		$tokens[] = [
			'type' => CExpressionParserResult::TOKEN_TYPE_STRING,
			'pos' => $pos,
			'match' => $matches[0],
			'length' => $len
		];
		$pos += $len - 1;

		return true;
	}

	/**
	 * Unquoting quoted string $value.
	 *
	 * @param string $value
	 *
	 * @return string
	 */
	public static function unquoteString(string $value): string {
		return strtr(substr($value, 1, -1), ['\\"' => '"', '\\\\' => '\\']);
	}

	/**
	 * Quoting $value if it contains a non numeric value.
	 *
	 * @param string $value
	 * @param bool   $allow_macros
	 * @param bool   $force
	 *
	 * @return string
	 */
	public static function quoteString(string $value, bool $allow_macros = true, bool $force = false): string {
		if (!$force) {
			$number_parser = new CNumberParser(['with_size_suffix' => true, 'with_time_suffix' => true]);

			if ($number_parser->parse($value) == CParser::PARSE_SUCCESS) {
				return $value;
			}

			if ($allow_macros) {
				$parser_options = ['macros' => ['{TRIGGER.VALUE}']];
				if ((new CMacroParser($parser_options))->parse($value) == CParser::PARSE_SUCCESS
						|| (new CMacroFunctionParser($parser_options))->parse($value) == CParser::PARSE_SUCCESS
						|| (new CUserMacroParser)->parse($value) == CParser::PARSE_SUCCESS
						|| (new CUserMacroFunctionParser)->parse($value) == CParser::PARSE_SUCCESS
						|| (new CLLDMacroParser)->parse($value) == CParser::PARSE_SUCCESS
						|| (new CLLDMacroFunctionParser)->parse($value) == CParser::PARSE_SUCCESS) {
					return $value;
				}
			}
		}

		return '"'.strtr($value, ['\\' => '\\\\', '"' => '\\"']).'"';
	}

	/**
	 * Returns an expression parser result.
	 *
	 * @return null|CExpressionParserResult
	 */
	public function getResult(): ?CExpressionParserResult {
		return $this->result;
	}

	/**
	 * Returns a friendly error message or empty string if expression was parsed successfully.
	 *
	 * @return string
	 */
	public function getError(): string {
		return $this->error;
	}
}
