<?php

namespace JmesPath;

use ArrayAccess;
use RuntimeException;
use stdClass;

/**
 * Tree visitor used to evaluates JMESPath AST expressions.
 */
class TreeInterpreter {
	/** @var callable */
	private $fnDispatcher;

	/**
	 * @param callable $fnDispatcher Function dispatching function that accepts
	 *                               a function name argument and an array of
	 *                               function arguments and returns the result.
	 */
	public function __construct( callable $fnDispatcher = null ) {
		$this->fnDispatcher = $fnDispatcher ?: FnDispatcher::getInstance();
	}

	/**
	 * Visits each node in a JMESPath AST and returns the evaluated result.
	 *
	 * @param array $node JMESPath AST node
	 * @param mixed $data Data to evaluate
	 *
	 * @return mixed
	 */
	public function visit( array $node, $data ) {
		return $this->dispatch( $node, $data );
	}

	/**
	 * Recursively traverses an AST using depth-first, pre-order traversal.
	 * The evaluation logic for each node type is embedded into a large switch
	 * statement to avoid the cost of "double dispatch".
	 * @return mixed
	 */
	private function dispatch( array $node, $value ) {
		$dispatcher = $this->fnDispatcher;

		switch ( $node['type'] ) {

			case 'field':
				if ( is_array( $value ) || $value instanceof ArrayAccess ) {
					return isset( $value[ $node['value'] ] ) ? $value[ $node['value'] ] : null;
				} elseif ( $value instanceof stdClass ) {
					return isset( $value->{$node['value']} ) ? $value->{$node['value']} : null;
				}

				return null;

			case 'subexpression':
				return $this->dispatch(
					$node['children'][1],
					$this->dispatch( $node['children'][0], $value )
				);

			case 'index':
				if ( ! Utils::isArray( $value ) ) {
					return null;
				}
				$idx = $node['value'] >= 0
					? $node['value']
					: $node['value'] + count( $value );

				return isset( $value[ $idx ] ) ? $value[ $idx ] : null;

			case 'projection':
				$left = $this->dispatch( $node['children'][0], $value );
				switch ( $node['from'] ) {
					case 'object':
						if ( ! Utils::isObject( $left ) ) {
							return null;
						}
						break;
					case 'array':
						if ( ! Utils::isArray( $left ) ) {
							return null;
						}
						break;
					default:
						if ( ! is_array( $left ) || ! ( $left instanceof stdClass ) ) {
							return null;
						}
				}

				$collected = [];
				foreach ( (array) $left as $val ) {
					$result = $this->dispatch( $node['children'][1], $val );
					if ( $result !== null ) {
						$collected[] = $result;
					}
				}

				return $collected;

			case 'flatten':
				static $skipElement = [];
				$value = $this->dispatch( $node['children'][0], $value );

				if ( ! Utils::isArray( $value ) ) {
					return null;
				}

				$merged = [];
				foreach ( $value as $values ) {
					// Only merge up arrays lists and not hashes
					if ( is_array( $values ) && isset( $values[0] ) ) {
						$merged = array_merge( $merged, $values );
					} elseif ( $values !== $skipElement ) {
						$merged[] = $values;
					}
				}

				return $merged;

			case 'literal':
				return $node['value'];

			case 'current':
				return $value;

			case 'or':
				$result = $this->dispatch( $node['children'][0], $value );

				return Utils::isTruthy( $result )
					? $result
					: $this->dispatch( $node['children'][1], $value );

			case 'and':
				$result = $this->dispatch( $node['children'][0], $value );

				return Utils::isTruthy( $result )
					? $this->dispatch( $node['children'][1], $value )
					: $result;

			case 'not':
				return ! Utils::isTruthy(
					$this->dispatch( $node['children'][0], $value )
				);

			case 'pipe':
				return $this->dispatch(
					$node['children'][1],
					$this->dispatch( $node['children'][0], $value )
				);

			case 'multi_select_list':
				if ( $value === null ) {
					return null;
				}

				$collected = [];
				foreach ( $node['children'] as $node ) {
					$collected[] = $this->dispatch( $node, $value );
				}

				return $collected;

			case 'multi_select_hash':
				if ( $value === null ) {
					return null;
				}

				$collected = [];
				foreach ( $node['children'] as $node ) {
					$collected[ $node['value'] ] = $this->dispatch(
						$node['children'][0],
						$value
					);
				}

				return $collected;

			case 'comparator':
				$left  = $this->dispatch( $node['children'][0], $value );
				$right = $this->dispatch( $node['children'][1], $value );
				if ( $node['value'] == '==' ) {
					return Utils::isEqual( $left, $right );
				} elseif ( $node['value'] == '!=' ) {
					return ! Utils::isEqual( $left, $right );
				} else {
					return self::relativeCmp( $left, $right, $node['value'] );
				}

			case 'condition':
				return Utils::isTruthy( $this->dispatch( $node['children'][0], $value ) )
					? $this->dispatch( $node['children'][1], $value )
					: null;

			case 'function':
				$args = [];
				foreach ( $node['children'] as $arg ) {
					$args[] = $this->dispatch( $arg, $value );
				}

				return $dispatcher( $node['value'], $args );

			case 'slice':
				return is_string( $value ) || Utils::isArray( $value )
					? Utils::slice(
						$value,
						$node['value'][0],
						$node['value'][1],
						$node['value'][2]
					) : null;

			case 'expref':
				$apply = $node['children'][0];

				return function ( $value ) use ( $apply ) {
					return $this->visit( $apply, $value );
				};

			default:
				throw new RuntimeException( "Unknown node type: {$node['type']}" );
		}
	}

	/**
	 * @return bool
	 */
	private static function relativeCmp( $left, $right, $cmp ) {
		if ( ! ( is_int( $left ) || is_float( $left ) ) || ! ( is_int( $right ) || is_float( $right ) ) ) {
			return false;
		}

		switch ( $cmp ) {
			case '>':
				return $left > $right;
			case '>=':
				return $left >= $right;
			case '<':
				return $left < $right;
			case '<=':
				return $left <= $right;
			default:
				throw new RuntimeException( "Invalid comparison: $cmp" );
		}
	}
}
