<?php
/**
 * Manages the diff and merge of the tracked items.
 *
 * @package Ealab\Deployer
 */

namespace Ealab\Deployer\Diff;

use Dakwamine\Component\ComponentBasedObject;
use Dakwamine\Component\RootDependencyDefinition;
use Ealab\Deployer\Exception\WpDeployException;
use Ealab\Deployer\Tracking\ItemValueHandler\ValueProcessor\PhpSerializedProcessor;
use Ealab\Deployer\Tracking\TrackingManager;
use Jfcherng\Diff\Differ as PhpDiff_Differ;
use Jfcherng\Diff\DiffHelper;
use Jfcherng\Diff\Exception\UnsupportedFunctionException;
use Jfcherng\Diff\Factory\RendererFactory;
use Matriphe\ISO639\ISO639;

/**
 * Manages the diff and merge of the tracked items.
 */
class Differ extends ComponentBasedObject {

	const DIFF_RESULT__MODIFIED = 'diff';

	const DIFF_RESULT__ERROR = 'error';

	const DIFF_RESULT__NEW = 'new';

	const DIFF_RESULT__SAME = 'same';

	/**
	 * Default renderer options.
	 *
	 * @var array|null
	 */
	private array $default_renderer_options;

	/**
	 * The tracking manager.
	 *
	 * @var TrackingManager|null
	 */
	private ?TrackingManager $tracking_manager = null;

	/**
	 * Compares two items.
	 *
	 * @param array<string,array> $local_item The local item.
	 * @param array<string,array> $incoming_item The incoming item to import into this instance.
	 * @param array<string,mixed> $options The options for the comparison. Contains the following keys:
	 *   - diff_mode (string): The diff mode. 'InteractiveSideBySide' or 'DscSideBySide'.
	 *   - compare_meta_fields (bool): Whether to include the meta fields in the diff. Default: false.
	 *
	 * @return array<string,array> The diff between the two items. Keyed by the full field name.
	 *   Values are arrays with the following keys:
	 *   - json_diff: the diff between the two values, as a JSON string.
	 *   - html_diff: the HTML table representing the diff between the two values.
	 *
	 * @throws WpDeployException Error while comparing the items.
	 */
	public function compareItem( array $local_item, array $incoming_item, array $options = array() ): array {
		// Compare each field separately, and do not miss the fields which are set only on one side.
		if ( ! isset( $local_item['fields'] ) || ! isset( $incoming_item['fields'] ) ) {
			throw new WpDeployException( 'Error while comparing the item fields: at least one of the items has missing fields.' );
		}

		$item_full_uuid = $this->tracking_manager->getUuidFromDeploymentItem( $local_item );

		if ( empty( $item_full_uuid ) ) {
			// Second chance with the incoming item.
			$item_full_uuid = $this->tracking_manager->getUuidFromDeploymentItem( $incoming_item );
		}

		if ( empty( $item_full_uuid ) ) {
			throw new WpDeployException( 'Error while comparing the item: the full UUID could not be determined.' );
		}

		// Start by extracting the fields to compare.
		$local_item_fields    = $local_item['fields'];
		$incoming_item_fields = $incoming_item['fields'];

		if ( empty( $options['compare_meta_fields'] ) ) {
			// Remove the meta fields from the comparison. This is the default behavior.
			// Get the tracker for the item.
			$item_tracker = $this->tracking_manager->getItemTrackerByItemFullUuid( $item_full_uuid );

			if ( empty( $item_tracker ) ) {
				throw new WpDeployException( 'Error while comparing the item: the item tracker of the item to compare could not be determined.' );
			}

			$meta_table_name = $item_tracker->getTrackingMetaDataTableName( false );

			$local_item_fields = array_filter(
				$local_item_fields,
				function ( $field_name ) use ( $meta_table_name ) {
					return false === strpos( $field_name, $meta_table_name . '.' );
				},
				ARRAY_FILTER_USE_KEY
			);

			$incoming_item_fields = array_filter(
				$incoming_item_fields,
				function ( $field_name ) use ( $meta_table_name ) {
					return false === strpos( $field_name, $meta_table_name . '.' );
				},
				ARRAY_FILTER_USE_KEY
			);
		}

		$reached_end_of_local_item    = key( $local_item_fields ) === null;
		$reached_end_of_incoming_item = key( $incoming_item_fields ) === null;

		// Contains the value for each field, for both the local and incoming items.
		$values_per_field = array();

		while ( true ) {
			if ( $reached_end_of_local_item && $reached_end_of_incoming_item ) {
				break;
			}

			$local_item_full_field_name = key( $local_item_fields );
			$local_item_value           = current( $local_item_fields );

			$incoming_item_full_field_name = key( $incoming_item_fields );
			$incoming_item_value           = current( $incoming_item_fields );

			if ( null === $local_item_full_field_name ) {
				// We reached the end of the local item.
				// Only add the incoming item value.
				$values_per_field[ $incoming_item_full_field_name ]['incoming'] = $incoming_item_value;
			} elseif ( null === $incoming_item_full_field_name ) {
				// We reached the end of the incoming item.
				// Only add the local item value.
				$values_per_field[ $local_item_full_field_name ]['local'] = $local_item_value;
			} else {
				// Add both values.
				$values_per_field[ $local_item_full_field_name ]['local']       = $local_item_value;
				$values_per_field[ $incoming_item_full_field_name ]['incoming'] = $incoming_item_value;
			}

			( ! $reached_end_of_local_item ) && next( $local_item_fields );
			( ! $reached_end_of_incoming_item ) && next( $incoming_item_fields );

			$reached_end_of_local_item    = $reached_end_of_local_item || ( key( $local_item_fields ) === null );
			$reached_end_of_incoming_item = $reached_end_of_incoming_item || ( key( $incoming_item_fields ) === null );
		}

		// Remove the fields which have the same value on both sides.
		$values_per_field = array_filter(
			$values_per_field,
			function ( $field_values ) {
				$local_item_value    = $field_values['local'] ?? null;
				$incoming_item_value = $field_values['incoming'] ?? null;

				return $local_item_value !== $incoming_item_value;
			}
		);

		// Now, build the diffs.
		foreach ( $values_per_field as $full_field_name => $field_values ) {
			if ( false ) {
				// TODO Test code. À retirer.
				$field_values['local'] = <<<AAA
Qui omnis ut impedit omnis. Repellat non quis dolore accusamus eos dolorem rem. Harum sequi reiciendis voluptas quo nemo omnis. Voluptas qui repellendus numquam ut vel aut quasi.

Et voluptatem et sint sint voluptate sapiente aut aut. Reiciendis et error exercitationem voluptas ut. Voluptatibus voluptatem accusamus qui animi rem. Asperiores dolorem minima hic impedit eum. Officia quia repellendus eum et non accusantium. Necessitatibus omnis et magni dolor.

Aut consequuntur expedita ut. Qui perferendis molestiae repellat et placeat. Aut libero et inventore voluptatem amet dolor. Dolorum aperiam deleniti id ut vel. Dicta quaerat velit aut id.

Quibusdam corrupti voluptate officiis inventore nesciunt minima. Iste tenetur ullam voluptate architecto. Ratione libero deserunt nostrum ex accusantium blanditiis suscipit et. Quis vero saepe quo.




gg
Totam ut quod sed quia delectus dolor libero cum. Placeat magnam unde debitis provident officiis quaerat sapiente qui. Vel est suscipit facere ea reprehenderit a. Accusamus id qui et quia.

AAA;

				$field_values['incoming'] = 'toto' . "\r\n" . <<<BBB


Qui omnis ut impedit omnis. Repellat non quis dolore accusamus eos dolorem rem. Harum sequi reiciendis voluptas quo nemo omnis. Voluptas qui repellendus numquam ut vel aut quasi.

Et voluptatem et sint sint voluptate sapiente aut aut. Reiciendis et error exercitationem voluptas ut. Voluptatibus voluptatem accusamus qui animi rem. Asperiores dolorem minima hic impedit eum. Officia quia repellendus eum et non accusantium. Necessitatibus omnis et magni dolor.



	Aut consequuntur expedita ut. Qui perferendis molestiae repellat et placeat. Aut libero et inventore voluptatem amet dolor. Dolorum aperiam deleniti id ut vel. Dicta quaerat velit aut id.

Quibusdam corrupti voluptate officiis inventore nesciunt minima. Iste tenetur ullam voluptate architecto. Ratione libero deserunt nostrum ex accusantium blanditiis suscipit et. Quis vero saepe quo.
g
Totam ut quod sed quia delectus dolor libero cum. Placeat magnam unde debitis provident officiis quaerat sapiente qui. Vel est suscipit facere ea reprehenderit a. Accusamus id qui et quia.

BBB;
			}

			$local_item_value    = $field_values['local'] ?? null;
			$incoming_item_value = $field_values['incoming'] ?? null;

			try {
				$throwExceptionOnInvalid = function ( $value ) {
					if ( substr_count( $value, "\n" ) > 980 ) {
						// We don't want to compare too many lines
						// because we would reach the POST max input vars limit.
						throw new \Exception( 'Too many lines.' );
					}

					return $value;
				};

				// If the values are serialized ones, show the YAML version of them if both are valid.
				$unserialized_local_value    = PhpSerializedProcessor::getUnserializedValue( $local_item_value );
				$unserialized_incoming_value = PhpSerializedProcessor::getUnserializedValue( $incoming_item_value );

				$diff_ready_local_value    = $throwExceptionOnInvalid( PhpSerializedProcessor::convertUnserializedToDiffVersion( $unserialized_local_value ) );
				$diff_ready_incoming_value = $throwExceptionOnInvalid( PhpSerializedProcessor::convertUnserializedToDiffVersion( $unserialized_incoming_value ) );

				$local_item_value    = $diff_ready_local_value;
				$incoming_item_value = $diff_ready_incoming_value;

				$values_per_field[ $full_field_name ]['uses_php_serialized_processor'] = true;
			} catch ( \Throwable $e ) {
				// Use the default values.
			}

			// Compare the fields.
			$json_diff = $this->getDiffAsJson( $local_item_value ?? null, $incoming_item_value ?? null, $options );

			$item_options = array( 'diff_id' => $item_full_uuid . ':' . $full_field_name );

			if ( $values_per_field[ $full_field_name ]['uses_php_serialized_processor'] ?? false ) {
				$item_options['uses_php_serialized_processor'] = true;
			}

			$options = array_merge( $options, $item_options );

			$html_diff = $this->renderJsonResultAsHtml( $json_diff, $options );

			$values_per_field[ $full_field_name ]['json_diff'] = $json_diff;
			$values_per_field[ $full_field_name ]['html_diff'] = $html_diff;
		}

		// Sort the fields by name to get a consistent order.
		ksort( $values_per_field );

		return $values_per_field;
	}

	/**
	 * The renderer options.
	 *
	 * @return array The renderer options array.
	 */
	private function getDefaultRendererOptions(): array {
		if ( ! isset( $this->default_renderer_options ) ) {
			// The php-diff package uses ISO 639-2/B language codes.
			$iso         = new ISO639();
			$ui_language = $iso->code2bByLanguage( $iso->languageByCode1( explode( '_', get_locale() )[0] ) );

			$this->default_renderer_options = array(
				// Differ::CONTEXT_ALL will show all the contexts around the diff.
				// This is needed for our merge view.
				'context'           => PhpDiff_Differ::CONTEXT_ALL,
				'detailLevel'       => 'char',
				'language'          => $ui_language,
				'outputTagAsString' => true,
			);
		}

		return $this->default_renderer_options;
	}

	/**
	 * [@inheritDoc}
	 */
	public function getDependencyDefinitions(): array {
		$definitions   = parent::getDependencyDefinitions();
		$definitions[] = new RootDependencyDefinition( TrackingManager::class, $this->tracking_manager );

		return $definitions;
	}

	/**
	 * Gets the diff between two strings as JSON result.
	 *
	 * @param string|null         $a Local value.
	 * @param string|null         $b Incoming value.
	 * @param array<string,mixed> $additional_renderer_options Diff renderer options. Includes the following:
	 *   - diff_id (string): The ID of the diff.
	 *
	 * @return string The diff as JSON result.
	 */
	public function getDiffAsJson( ?string $a, ?string $b, array $additional_renderer_options = array() ): string {
		// Dev note: the way it's currently implemented, the diff works only with \n (LF) line endings.
		// It still works with \r\n (CRLF). But it will not properly break the data ended only by \r (CR) characters.
		// If we want to support \r (CR) line endings, we need to detect the line endings (usually the first is fine),
		// split ourselves the strings, then inform the renderer what line endings to use for building the
		// input for the merge system (i.e. InteractiveSideBySide).
		$base_options = $this->getDefaultRendererOptions();

		if ( ( is_string( $a ) && strlen( $a ) > 10000 ) || ( is_string( $a ) && strlen( $b ) > 10000 ) ) {
			// Ask the diff to use simpler methods to reduce the memory usage.
			// Dev note: with this "line" setting, the peak memory usage can still
			// be high, around 40-50 MB for a 1.75 MiB diff.
			// If it's still a problem, we should disable the diff for large strings.
			$base_options['detailLevel'] = 'line';
		}

		$calculated = DiffHelper::calculate(
			$a ?? '',
			$b ?? '',
			'JsonHtmlWithRaw',
			$base_options,
			array_merge( $base_options, $additional_renderer_options )
		);

		$to_reencode = false;

		$doo = function ( $operation ) use ( &$calculated, &$to_reencode ) {
			// This will decode the result once.
			$calculated  = $to_reencode ? $calculated : json_decode( $calculated, true );
			$to_reencode = true;

			foreach ( $calculated as &$item ) {
				$item = array_map(
					function ( $item ) use ( $operation ) {
						$item['tag'] = $operation;

						return $item;
					},
					$item
				);

				unset( $item );
			}
		};

		if ( null === $a && null === $b ) {
			return $calculated;
		}

		if ( null === $a ) {
			$doo( 'ins' );
		} elseif ( null === $b ) {
			$doo( 'del' );
		}

		if ( $to_reencode ) {
			$calculated = wp_json_encode( $calculated );
		}

		return $calculated;
	}

	/**
	 * Renders the JSON result as HTML.
	 *
	 * @param string              $json The JSON result.
	 * @param array<string,mixed> $options The renderer options. Contains the following keys:
	 *   - diff_mode (string): The diff mode. 'InteractiveSideBySide' or 'DscSideBySide'.
	 *
	 * @return string The HTML result.
	 */
	public function renderJsonResultAsHtml( string $json, array $options = array() ): string {
		$renderer = ( $options['diff_mode'] ?? 'DscSideBySide' ) === 'InteractiveSideBySide' ? 'InteractiveSideBySide' : 'DscSideBySide';

		$html_renderer = RendererFactory::make( $renderer, array_merge( $this->getDefaultRendererOptions(), $options ) );

		try {
			return $html_renderer->renderArray( json_decode( $json, true ) );
		} catch ( UnsupportedFunctionException $e ) {
			// This should never happen, so simply ignore the error.
			return '';
		}
	}
}
