<?php
/**
 * Extracts data from the given variable using a pattern.
 *
 * @package Ealab\Deployer
 */

namespace Ealab\Deployer\Utility;

use Exception;
use ReflectionClass;
use ReflectionException;

/**
 * Extracts data from the given variable using a pattern.
 *
 * It takes any variable, then performs a search.
 * The results are returned by reference in an array.
 *
 * @author Quang-Minh DANG <quang-minh@emerya.fr>
 */
class DataWalker {
	private const RESULT_STATUS__FAILURE = 0;
	private const RESULT_STATUS__SUCCESS = 1;

	/**
	 * Handles the `arr` command.
	 *
	 * @param array  $data The values to process.
	 * @param string $command The command to execute.
	 *
	 * @return array The status [+ values] of the result.
	 */
	private static function handleArrayCommand( array &$data, string $command ): array {
		$command_parts = explode( ':', $command );

		if ( 'arr' !== $command_parts[0] ) {
			return [
				'status' => static::RESULT_STATUS__FAILURE,
			];
		}
		$result = [];

		foreach ( $data as &$item ) {
			if ( is_array( $item ) ) {
				foreach ( $item as &$subitem ) {
					$result[] = &$subitem;
				}
			}
		}

		return [
			'status' => static::RESULT_STATUS__SUCCESS,
			'value'  => &$result,
		];
	}

	/**
	 * Entrypoint for command handling.
	 *
	 * Given the data and command, it tries to extract the data by calling the appropriate handler.
	 *
	 * @param array  $data The values to process.
	 * @param string $command The command to execute.
	 *
	 * @return array The result of the command:
	 *     - "status" (int): The status of the result.
	 *       Either `static::RESULT_STATUS__FAILURE` or `static::RESULT_STATUS__SUCCESS`.
	 *     - "value" (mixed): The value of the result, by reference.
	 *       Only set if the status is `static::RESULT_STATUS__SUCCESS`.
	 */
	private static function handleCommand( array &$data, string $command ): array {
		// The list of command handlers to try.
		// Place the most common ones first.
		$command_handlers = [
			'handlePropertyCommand',
			'handleOffsetCommand',
			'handleArrayCommand',
			'handleObjectCommand',
			'handleRecursiveExpandCommand',
		];

		foreach ( $command_handlers as $command_handler ) {
			/**
			 * The result of the command handler. It's an array containing the status and an array of values if valid.
			 *
			 * @var array $handler_result
			 */
			$handler_result = static::$command_handler( $data, $command );

			if ( static::RESULT_STATUS__FAILURE === $handler_result['status'] ) {
				continue;
			}

			return $handler_result;
		}

		// No suitable handler found.
		return [
			'status' => static::RESULT_STATUS__FAILURE,
		];
	}

	/**
	 * Handles the `obj` command.
	 *
	 * @param array  $data The values to process.
	 * @param string $command The command to execute.
	 *
	 * @return array The status [+ values] of the result.
	 */
	private static function handleObjectCommand( array &$data, string $command ): array {
		$command_parts = explode( ':', $command );

		if ( 'obj' !== $command_parts[0] ) {
			return [
				'status' => static::RESULT_STATUS__FAILURE,
			];
		}

		$command_parts_count = count( $command_parts );

		if ( 2 < $command_parts_count ) {
			// Invalid command.
			return [
				'status' => static::RESULT_STATUS__FAILURE,
			];
		} elseif ( 1 === $command_parts_count ) {
			$expected_class = null;
		} else {
			$expected_class = $command_parts[1];
		}

		foreach ( $data as &$item ) {
			if ( ! is_object( $item ) ) {
				continue;
			}

			if ( null === $expected_class ) {
				$result[] = &$item;
			} else {
				if ( get_class( $item ) === $expected_class ) {
					$result[] = &$item;
				}
			}
		}

		return [
			'status' => static::RESULT_STATUS__SUCCESS,
			'value'  => &$result,
		];
	}

	/**
	 * Handles the `off` command.
	 *
	 * @param array  $data The values to process.
	 * @param string $command The command to execute.
	 *
	 * @return array The status [+ values] of the result.
	 */
	private static function handleOffsetCommand( array &$data, string $command ): array {
		$command_parts = explode( ':', $command );

		if ( $command_parts[0] !== 'off' ) {
			return [
				'status' => static::RESULT_STATUS__FAILURE,
			];
		}

		/**
		 * The command may have 2 or more parts.
		 * - "off:<offset>": Return the value at the given offset.
		 * - "off::<length>": Values, limited by length.
		 * - "off:><offset>": Values at the offsets starting from the given offset, excluded.
		 * - "off:>=<offset>": Values at the offsets starting from the given offset, included.
		 * - 'off:<<offset>': Values at the offsets until the given offset, excluded.
		 * - 'off:<=<offset>': Values at the offsets until the given offset, included.
		 * - 'off:<offset>to<offset_2>': Values between the given offsets, included.
		 * - "off:<offset>,<offset>": Cumulate results (AND).
		 */
		$command_parts_count = count( $command_parts );

		if ( 2 !== $command_parts_count ) {
			return [
				'status' => static::RESULT_STATUS__FAILURE,
			];
		}

		// Split the command value by comma.
		$rules = explode( ',', $command_parts[1] );

		$result = [];

		foreach ( $data as &$value ) {
			$index = 0;

			if ( ! is_iterable( $value ) ) {
				// Not a value we can iterate over to get an offset value.
				continue;
			}

			// Iterate over the data.
			foreach ( $value as &$realValue ) {
				// Check if the index is in the rules.
				foreach ( $rules as $rule ) {
					$substr2 = substr( $rule, 0, 2 );

					if ( '<=' === $substr2 ) {
						$offset = intval( substr( $rule, 2 ) );

						if ( $index <= $offset ) {
							// The index is in the range.
							$result[] = &$realValue;
						}

						continue 2;
					}

					if ( '>=' === $substr2 ) {
						$offset = intval( substr( $rule, 2 ) );

						if ( $index >= $offset ) {
							// The index is in the range.
							$result[] = &$realValue;
						}

						continue 2;
					}

					$substr1 = substr( $rule, 0, 1 );

					if ( '<' === $substr1 ) {
						$offset = intval( substr( $rule, 1 ) );

						if ( $index < $offset ) {
							// The index is in the range.
							$result[] = &$realValue;
						}

						continue 2;
					}

					if ( '>' === $substr1 ) {
						$offset = intval( substr( $rule, 1 ) );

						if ( $index > $offset ) {
							// The index is in the range.
							$result[] = &$realValue;
						}

						continue 2;
					}

					if ( strpos( $rule, 'to' ) !== false ) {
						$rule_parts = explode( 'to', $rule );
						$offset_1   = intval( trim( $rule_parts[0] ) );
						$offset_2   = intval( trim( $rule_parts[1] ) );

						if ( $index >= $offset_1 && $index <= $offset_2 ) {
							// The index is in the range.
							$result[] = &$realValue;
						}

						continue 2;
					}

					// Try to process the rule as a single offset.
					$offset = intval( $rule );

					if ( $index === $offset ) {
						$result[] = &$realValue;
					}
				}

				++$index;
			}
		}

		return [
			'status' => static::RESULT_STATUS__SUCCESS,
			'value'  => &$result,
		];
	}

	/**
	 * Handles the `prop` command.
	 *
	 * @param array  $data The values to process.
	 * @param string $command The command to execute.
	 *
	 * @return array The status [+ values] of the result.
	 */
	private static function handlePropertyCommand( array &$data, string $command ): array {
		$command_parts = explode( ':', $command );

		if ( 'prop' !== $command_parts[0] ) {
			return [
				'status' => static::RESULT_STATUS__FAILURE,
			];
		}

		if ( 2 !== count( $command_parts ) ) {
			return [
				'status' => static::RESULT_STATUS__FAILURE,
			];
		}

		$prop_name = $command_parts[1];

		$result = [];

		foreach ( $data as &$value ) {
			if ( is_array( $value ) ) {
				if ( ! array_key_exists( $prop_name, $value ) ) {
					continue;
				}

				$result[] = &$value[ $prop_name ];
				continue;
			}

			if ( ! is_object( $value ) ) {
				continue;
			}

			try {
				// Check if the object is internal.
				$userclass = new ReflectionClass( $value );
			} catch ( ReflectionException $e ) {
				// Usually means that the class does not exist.
				// As we are only sending objects, and not class name strings, in the ReflectionClass constructor,
				// this error should logically never happen. But let's prevent errors that may occur in the future
				// implementations of the ReflectionClass.
				$userclass = null;
			}

			if ( empty( $userclass ) || $userclass->isInternal() ) {
				// Perform a simple property check, as we cannot access private or protected properties.
				if ( ! property_exists( $value, $prop_name ) ) {
					continue;
				}

				$property = &$value->$prop_name;

				$result[] = &$property;
				continue;
			}

			try {
				// "User defined" class.
				// Because properties can be private, protected, or public,
				// we need to use special means to access them.
				$property = \Closure::fromCallable(
					function () use ( $prop_name ) {
						// Check that the property exists.
						if ( ! property_exists( $this, $prop_name ) ) {
							throw new Exception( 'Property ' . $prop_name . ' does not exist on ' . get_class( $this ) );
						}

						$val = &$this->$prop_name;

						return $val;
					}
				)->call( $value );

				$result[] = &$property;
			} catch ( Exception $e ) {
				continue;
			}
		}

		return [
			'status' => static::RESULT_STATUS__SUCCESS,
			'value'  => &$result,
		];
	}

	/**
	 * Handles the `rec` command.
	 *
	 * @param array  $data The values to process.
	 * @param string $command The command to execute.
	 *
	 * @return array The status [+ values] of the result.
	 */
	private static function handleRecursiveExpandCommand( array &$data, string $command ): array {
		$command_parts = explode( ':', $command );

		if ( 'rec' !== $command_parts[0] ) {
			return [
				'status' => static::RESULT_STATUS__FAILURE,
			];
		}

		$default_max_recursion_count = 100;
		$max_recursion_count         = filter_var( ( $command_parts[1] ?? $default_max_recursion_count ), FILTER_VALIDATE_INT );

		if ( false === $max_recursion_count ) {
			$max_recursion_count = $default_max_recursion_count;
		}

		// Expand all items found by recursion in the result.
		$result = [];

		// This is used to limit infinite loops due to references looping back to ancestors.
		$object_recursion_stack = [];

		$current_recursion_count = 0;

		$recurse = function ( &$value ) use ( &$result, &$recurse, &$object_recursion_stack, &$current_recursion_count, $max_recursion_count ) {
			if ( is_object( $value ) && in_array( $value, $object_recursion_stack, true ) ) {
				// The value is already in the object recursion stack.
				// We should not recurse into it to prevent infinite loops.
				return;
			}

			// Add this value to the result.
			$result[] = &$value;

			if ( $current_recursion_count >= $max_recursion_count ) {
				// We have reached the maximum recursion depth.
				// We should not recurse into this value.
				return;
			}

			++$current_recursion_count;

			if ( is_object( $value ) ) {
				// Add this object to the object recursion stack.
				$object_recursion_stack[] = $value;

				try {
					// Check if the object is internal.
					$userclass = new ReflectionClass( $value );
				} catch ( ReflectionException $e ) {
					$userclass = null;
				}

				if ( empty( $userclass ) || $userclass->isInternal() ) {
					// Iterate over accessible properties.
					$prop_names = array_keys( get_object_vars( $value ) );

					// We do this to get the properties by reference.
					foreach ( $prop_names as $prop_name ) {
						$prop = &$value->$prop_name;
						$recurse( $prop );
					}
				} else {
					try {
						// "User defined" class.
						// Because properties can be private, protected, or public,
						// we need to use special means to access them.
						$property = \Closure::fromCallable(
							function () use ( $recurse ) {
								// Iterate over accessible properties.
								$prop_names = array_keys( get_object_vars( $this ) );

								// We do this to get the properties by reference.
								foreach ( $prop_names as $prop_name ) {
									$prop = &$this->$prop_name;
									$recurse( $prop );
								}
							}
						)->call( $value );

						$result[] = &$property;
					} catch ( Exception $e ) {
						// Do nothing.
					}
				}

				// Remove this object from the object recursion stack.
				array_pop( $object_recursion_stack );
			} elseif ( is_array( $value ) ) {
				foreach ( $value as &$subvalue ) {
					// Recurse into the value.
					$recurse( $subvalue );
				}
			}

			--$current_recursion_count;
		};

		foreach ( $data as &$value ) {
			$recurse( $value );
		}

		return [
			'status' => static::RESULT_STATUS__SUCCESS,
			'value'  => &$result,
		];
	}

	/**
	 * Walks in the data values located by pattern and return the value(s) found by reference.
	 *
	 * @param mixed  $data The data to walk in. Can be any type.
	 * @param string $pattern The pattern to use to walk in the data.
	 *
	 * @return array The value(s) found, by reference. Type depends on the given data and pattern.
	 *     Editing the returned value will affect the original data.
	 *
	 * @throws Exception If the pattern is invalid or did not give any result.
	 */
	public static function walk( &$data, string $pattern ): array {
		/**
		 * Pattern example:
		 *
		 * @code
		 * prop:6:prop.X: The value keyed by "prop.X" on the root level object or array.
		 * obj: The objects in the given data (iterable expected).
		 * obj.ClassA: The objects, if instance of "ClassA" (as returned by ::class and get_class()).
		 * obj.prop:6:prop.X: The value keyed by "prop.X" on the objects.
		 * obj:ClassA.prop:6:prop.X: The value keyed by "prop.X" on the objects of class ClassA.
		 * arr: The arrays in the given data (iterable expected).
		 * arr.prop:6:prop.X: The value of "prop.X" on the arrays.
		 * arr.off:0: The first offset of the arrays.
		 * arr.off:0.obj:ClassA: The ClassA references found on the first offset of the arrays.
		 * arr.off:0.obj:ClassA.prop:6:prop.X: The value of "prop.X" on the ClassA references
		 *     found on the first offset of the root level array.
		 * rec: Recursive expansion starting from the root level (limited by 100).
		 * rec:1: Recursive expansion on maximum 1 level.
		 * rec.obj: All objects found recursively.
		 * rec:1.obj: All objects found recursively on 1 level.
		 * rec.arr: All arrays found recursively.
		 * rec.obj:ClassA: All ClassA references found recursively.
		 * rec.obj.prop:6:prop.X: All values of "prop.X" found recursively.
		 * @endcode
		 */

		// Break the pattern into commands.
		$commands = [];

		// Take into account that props can have dots and special chars in their names;
		// that's why we specify the property name length: prop:6:prop.X
		$remaining_pattern_to_process = $pattern;

		while ( 0 !== strlen( $remaining_pattern_to_process ) ) {
			// This is used to prevent infinite loops.
			$remaining_pattern_to_process_start = $remaining_pattern_to_process;

			$parts               = explode( '.', $remaining_pattern_to_process, 2 );
			$is_property_command = substr( $parts[0], 0, strlen( 'prop:' ) ) === 'prop:';

			if ( $is_property_command ) {
				// Switch to length delimiter.
				$prop_parts = explode( ':', $remaining_pattern_to_process, 3 );

				if ( count( $prop_parts ) !== 3 ) {
					throw new Exception( 'Invalid pattern: ' . $pattern );
				}

				$prop_name_length = $prop_parts[1];

				if ( empty( $prop_name_length ) ) {
					throw new Exception( 'Invalid pattern: ' . $pattern );
				}

				$prop_name = substr( $prop_parts[2], 0, $prop_name_length );

				// Simplify the command by removing the length delimiter.
				$prop_commands = $prop_parts[0] . ':' . $prop_name;
				$commands[]    = $prop_commands;

				// +1 for the dot, if any.
				$remaining_pattern_to_process = substr( $prop_parts[2], $prop_name_length + 1 );
			} else {
				$commands[]                   = $parts[0];
				$remaining_pattern_to_process = $parts[1] ?? '';
			}

			if ( $remaining_pattern_to_process === $remaining_pattern_to_process_start ) {
				// The pattern is not being reduced.
				throw new Exception( 'Unsupported pattern: ' . $pattern );
			}
		}

		// We encapsulate the data into an array to make it easier to handle by reference.
		$reduced_data = [ &$data ];

		foreach ( $commands as $command ) {
			// Get the data for the command.
			$handled_data = static::handleCommand( $reduced_data, $command );

			if ( static::RESULT_STATUS__FAILURE === $handled_data['status'] ) {
				// No command gave a successful result.
				throw new Exception( 'No match for the pattern: ' . $pattern );
			}

			// Disconnect the reference.
			unset( $reduced_data );

			// Assign the new data by reference.
			$reduced_data = &$handled_data['value'];
		}

		return $reduced_data;
	}

	/**
	 * Walks in the data values located by pattern and applies the callback on the reduced data found.
	 *
	 * @param mixed    $data The data to walk in. Can be any type.
	 * @param string   $pattern The pattern to use to walk in the data.
	 * @param callable $callback The callback to apply on the reduced data found.
	 *     The callback is given one parameter: the reduced data found (which may be processed by reference).
	 */
	public static function walkAndApply( &$data, string $pattern, callable $callback ): void {
		try {
			$reduced_data = self::walk( $data, $pattern );
		} catch ( Exception $e ) {
			// The pattern did not give any result.
			return;
		}

		foreach ( $reduced_data as &$value ) {
			$callback( $value );
		}
	}
}
