<?php
/**
 * Makes PHP serialized values readable on diffs.
 *
 * @package Ealab\Deployer
 */

namespace Ealab\Deployer\Tracking\ItemValueHandler\ValueProcessor;

use Ealab\Deployer\Exception\WpDeployException;
use Ealab\Deployer\Tracking\ItemValueHandler\ItemValueHandlerBase;
use Ealab\Deployer\Utility\Translator;
use Symfony\Component\Yaml\Yaml;

/**
 * Enhances ("prettifies") PHP serialized values readability on diffs.
 *
 * WARNING: Do not use unserialize() when objects are involved in the serialized value.
 * Compare such values with the uglified version, not the prettified one.
 */
class PhpSerializedProcessor extends ValueProcessorBase {
	/**
	 * Converts a YAML value, prettified for diffs, to a PHP serialized value.
	 *
	 * @param string $value The value to convert.
	 *
	 * @return string The value, converted to a PHP serialized value.
	 *
	 * @throws WpDeployException If the value could not be serialized back.
	 */
	public static function convertDiffVersionToPhpSerialized( string $value ): string {
		return serialize( static::convertDiffVersionToUnserialized( $value ) );
	}

	/**
	 * Converts a diff version of a PHP serialized value to an unserialized value.
	 *
	 * @param string $value The value to convert.
	 *
	 * @return mixed The unserialized value.
	 *
	 * @throws WpDeployException If the value could not be unserialized.
	 */
	public static function convertDiffVersionToUnserialized( string $value ) {
		try {
			// PARSE_EXCEPTION_ON_INVALID_TYPE: Exception when trying to inject a PHP object.
			$unserialized = Yaml::parse( $value, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE );
		} catch ( \Throwable $e ) {
			throw new WpDeployException( 'Could not parse the given YAML value.' );
		}

		return $unserialized;
	}

	/**
	 * Converts a PHP serialized value to a YAML value, prettified for diffs.
	 *
	 * @param string $value The value to prettify.
	 *
	 * @return string The value, prettified for diffs.
	 *
	 * @throws WpDeployException If the value could not be unserialized.
	 */
	public static function convertPhpSerializedToDiffVersion( string $value ): string {
		return static::convertUnserializedToDiffVersion( static::getUnserializedValue( $value ) );
	}

	/**
	 * Converts an unserialized PHP value to a YAML value, prettified for diffs.
	 *
	 * @param mixed $value The PHP value to convert.
	 *
	 * @return string The value, prettified for diffs.
	 *
	 * @throws WpDeployException If the value could not be serialized to diff version.
	 */
	public static function convertUnserializedToDiffVersion( $value ): string {
		// PHP_INT_MAX: Never use the inline notation, because it makes content unmergeable.
		return Yaml::dump( $value, PHP_INT_MAX, 2 );
	}

	/**
	 * {@inheritDoc}
	 */
	public static function getId(): string {
		return 'php_serialized';
	}

	/**
	 * {@inheritDoc}
	 */
	public static function getLabel(): string {
		return Translator::get()->pgettext( 'processor label', 'PHP serialized' );
	}

	/**
	 * Unserializes the given value.
	 *
	 * @param mixed $value The value to check.
	 *
	 * @return mixed The unserialized value.
	 *
	 * @throws WpDeployException If the value is not allowed to be unserialized for diffs.
	 */
	public static function getUnserializedValue( $value ) {
		if ( ! is_string( $value ) ) {
			throw new WpDeployException( 'The value to unserialize is not a string.' );
		} elseif ( ! is_serialized( $value ) ) {
			throw new WpDeployException( 'The value to unserialize is not a serialized value.' );
		} elseif ( preg_match( '/[oO]:\d+:"[^"]*"/', $value ) ) {
			// We don't want to process objects, because they are not mergeable.
			throw new WpDeployException( 'The value to unserialize contains an object.' );
		}

		// Try to unserialize the value.
		return static::unserialize( $value );
	}

	/**
	 * {@inheritDoc}
	 */
	public function process( $value, int $operation, ?callable $progress_callback = null ) {
		// This processor is usually used when diffing values, not when exporting them.
		// So it has been designed to be used during diffs and merges.
		// But if ever needed, this processor could be used to export values;
		// if so, it must be registered in the available processors like others.
		if ( ! is_string( $value ) ) {
			// We only process strings here.
			return $value;
		}

		switch ( $operation ) {
			case ItemValueHandlerBase::OPERATION__EXPORT:
				// This is also the operation to use when we want to compare the values.
				return static::convertPhpSerializedToDiffVersion( $value );

			case ItemValueHandlerBase::OPERATION__IMPORT__FINALIZE:
			case ItemValueHandlerBase::OPERATION__IMPORT__PRESAVE:
				$as_php_serialized = static::convertDiffVersionToPhpSerialized( $value );

				if ( ! is_serialized( $as_php_serialized ) ) {
					// Either the value was not a serialized value (so we can import the incoming value as-is),
					// or the merge has created inconsistencies.
					throw new WpDeployException( 'The serialized value to import is not valid.' );
				}

				return $as_php_serialized;
		}

		return $value;
	}

	/**
	 * Unserializes a value, throwing an exception if it fails instead of a notice.
	 *
	 * @param string $value The value to unserialize.
	 *
	 * @return mixed The unserialized value.
	 *
	 * @throws WpDeployException If the value could not be unserialized.
	 */
	private static function unserialize( string $value ) {
		try {
			// This will intercept the notice thrown by unserialize() when the value is not a serialized value.
			set_error_handler(
				function ( $errno, $errstr ) {
					throw new WpDeployException( 'Could not unserialize the given value.' );
				}
			);

			return unserialize( $value );
		} finally {
			restore_error_handler();
		}
	}
}
