<?php
/**
 * Form Handler merge Class.
 *
 * @package EaLab\WP_Deployer
 */

namespace EaLab\WP_Deployer\includes\controller;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

require_once DEPLOY_SYNC_CONTENT__PLUGIN_DIR . 'includes/helpers.php';

use EaLab\WP_Deployer\vendor\Dakwamine\Component\ComponentBasedObject;
use EaLab\WP_Deployer\vendor\Dakwamine\Component\Exception\UnmetDependencyException;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Exception\WpDeployException;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Import\Importer;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\ItemValueHandler\ValueProcessor\PhpSerializedProcessor;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\TrackingManager;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\FileManager;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\PostFormDataAsJsonParser;

/**
 * Form Handler merge Class.
 */
class Merge_Form_Handler {
	/**
	 * Redirects to the deployment bundle page.
	 *
	 * @param array $get_params The GET params built by deploy_sync_content__form_handler_merge().
	 */
	private function redirect_to_deployment_bundle_page( array $get_params ): void {
		// Get the param name used by the calling context.
		switch ( $get_params['form_context'] ) {
			case 'import':
				$bundle_id_field_key = 'bundletoimport';
				break;

			case 'export':
			default:
				$bundle_id_field_key = 'bundle_id';
				break;
		}

		$destination = add_query_arg(
			array(
				'tab'                => $get_params['form_context'] ?? 'unknown_context',
				$bundle_id_field_key => $get_params['deployment_bundle'] ?? 'unknown_deployment_bundle',
			),
			admin_url( DEPLOY_SYNC_CONTENT__BASIC_URI )
		);

		wp_safe_redirect( $destination );
		exit;
	}

	/**
	 * Routing handler.
	 */
	public function deploy_sync_content__routing_handler_merge(): void {
		try {
			if ( 'POST' === ( $_SERVER['REQUEST_METHOD'] ?? null ) ) {
				// There is only the "merge" route that handles the merge submit.
				check_admin_referer( DEPLOY_SYNC_CONTENT__SLUG . 'merge' );
				$this->deploy_sync_content__form_handler_merge();
			}
		} catch ( WpDeployException $e ) {
			deploy_sync_content__error_notice( $e->getMessage() );
		}

		include_once DEPLOY_SYNC_CONTENT__PLUGIN_DIR . '/admin/partials/deploy-sync-content-admin-merge.php';
		deploy_sync_content__admin_merge();
	}

	/**
	 * Data submission handling.
	 *
	 * @throws WpDeployException All WP_Deployer exceptions.
	 */
	public function deploy_sync_content__form_handler_merge(): void {
		$get_params = array(
			'form_context'      => sanitize_text_field( wp_unslash( $_GET['form_context'] ?? '' ) ),
			'tab'               => sanitize_text_field( wp_unslash( $_GET['tab'] ?? '' ) ),
			'deployment_bundle' => sanitize_text_field( wp_unslash( $_GET['deployment_bundle'] ?? '' ) ),
		);

		if ( count( array_filter( $get_params ) ) !== count( $get_params ) ) {
			// One of the values is empty.
			return;
		}

		// Validate the bundle.
		if ( 'deployment_bundle' === 'unknown_bundle' ) {
			throw new WpDeployException( esc_html__( 'You cannot save data to an unknown bundle.', 'deploy-sync-content' ) );
		}

		$file_manager = $this->get_file_manager();

		$get_params['available_uuids'] = $file_manager->getAllAvailableUuidsForImport( $get_params['deployment_bundle'] );

		$all_uuids = sanitize_text_field( wp_unslash( $_GET['uuid'] ?? '' ) );

		$this->process_merge(
			array_merge(
				$get_params,
				array(
					'all_uuids_to_merge' => $all_uuids,
				)
			)
		);

		! empty( sanitize_text_field( wp_unslash( $_GET['redirect'] ?? '' ) ) ) && $this->redirect_to_deployment_bundle_page( $get_params );
	}

	/**
	 * Gets the file manager for Deploy & Sync Content.
	 *
	 * @return FileManager The file manager.
	 *
	 * @throws WpDeployException Unmanageable errors.
	 */
	private function get_file_manager(): FileManager {
		try {
			return ComponentBasedObject::getRootComponentByClassName( FileManager::class, true );
		} catch ( UnmetDependencyException $e ) {
			throw new WpDeployException( esc_html__( 'The Deploy & Sync Content plugin is not ready; a root component is not available.', 'deploy-sync-content' ) );
		}
	}

	/**
	 * This processes items of this merge POST. Multiple items are supported.
	 *
	 * @param array $get_params Data for processing.
	 *
	 * @throws WpDeployException Errors that cannot be handled here.
	 */
	private function process_merge( array $get_params ): void {
		$assembled_post = PostFormDataAsJsonParser::parseRawPostData();

		$post_merge_data = $assembled_post['merge'] ?? array();

		$post_uses_php_serialized_processor = $assembled_post['uses_php_serialized_processor'] ?? array();

		// Restructure $post_uses_php_serialized_processor to be indexed by the item uuid.
		$uses_php_serialized_processor = array();

		foreach ( $post_uses_php_serialized_processor as $uses_php_serialized_processor_value ) {
			$parts = explode( ':', $uses_php_serialized_processor_value, 3 );

			if ( count( $parts ) !== 2 ) {
				// We strictly want 2 parts.
				continue;
			}

			$uses_php_serialized_processor[ $parts[0] ][] = $parts[1];
		}

		// Revert the special characters that were escaped by the Differ in the keys.
		$keys = array_keys( $post_merge_data );
		array_walk(
			$keys,
			function ( &$v ) {
				$v = str_replace( array( '<dsc:dot>' ), array( '.' ), $v );
			}
		);
		$post_merge_data = array_combine( $keys, $post_merge_data );

		// Reorder the lines by key in natural order.
		ksort( $post_merge_data, SORT_NATURAL );

		// Split the post data into the different items.
		$expected_key_parts_count = count(
			array(
				'item_full_uuid',
				'full_field_name_without_db_prefix',
				'line_number',
			)
		);

		$all_file_content = array();

		foreach ( $post_merge_data as $key => $value ) {
			$key_parts = explode( ':', $key, $expected_key_parts_count + 1 );

			$key_parts = array_filter( $key_parts );

			if ( count( $key_parts ) !== $expected_key_parts_count ) {
				// This should never happen because it's our plugin that generates the keys.
				throw new WpDeployException( esc_html__( 'Invalid key format.', 'deploy-sync-content' ) );
			}

			// The value is JSON-encoded. Decode it.
			// Dev note: no need to wp_unslash the value, because we are not using the $_POST input.
			$decoded = json_decode( $value, true );

			if ( ! is_array( $decoded ) || ! array_key_exists( 'value', $decoded ) ) {
				// This should never happen because it's our plugin that generates the values.
				throw new WpDeployException( esc_html__( 'Invalid value format.', 'deploy-sync-content' ) );
			}

			if ( null === $decoded['value'] ) {
				// This is a deleted line.
				continue;
			}

			$all_file_content[ $key_parts[0] ][ $key_parts[1] ][] = $decoded['value'];
		}

		try {
			$tracking_manager = ComponentBasedObject::getRootComponentByClassName( TrackingManager::class, true );
			$importer         = ComponentBasedObject::getRootComponentByClassName( Importer::class, true );
		} catch ( UnmetDependencyException $e ) {
			throw new WpDeployException( esc_html__( 'The Deploy & Sync Content plugin is not ready; a root component is not available.', 'deploy-sync-content' ) );
		}

		foreach ( $get_params['all_uuids_to_merge'] as $uuid_to_merge ) {
			if ( ! array_key_exists( $uuid_to_merge, $all_file_content ) ) {
				// This case happens when the user submitted a merge for an item that does not contain
				// any field values to merge. This happens when the content
				// is identical between the local and incoming items.
				// The item still requires an update on the merge_date to not appear as conflicted,
				// so we must ensure that it is processed even when it has no changed values.
				$all_file_content[ $uuid_to_merge ] = array();
			}
		}

		// Perform a quick check to see if the items are still available.
		$all_uuids_to_edit = array_keys( $all_file_content );

		$file_manager = $this->get_file_manager();

		$items = $file_manager->loadDeploymentItemsData( $get_params['deployment_bundle'], $all_uuids_to_edit );

		if ( empty( $items ) || ( count( $all_uuids_to_edit ) !== count( $items ) ) ) {
			// This should rarely happen, because it means that one of the items has been deleted from the file system
			// between the moment the merge has started and the moment the merge has been submitted.
			throw new WpDeployException( esc_html__( 'The item you are trying to merge is not available anymore.', 'deploy-sync-content' ) );
		}

		// Perform the merge.
		foreach ( $all_file_content as $item_full_uuid => $field_content ) {
			// Retrieve the local and incoming metadata.
			$post_complete_metadata = json_decode( htmlspecialchars_decode( $assembled_post['complete_metadata'][ deploy_sync_content__encode_for_field_name( $item_full_uuid ) ] ) ?? '[]', true );

			if ( empty( $post_complete_metadata['local_item'] ) ) {
				// Ignore this item; we don't have the metadata.
				// This is either an invalid item, or this is an item which
				// does not exist yet in the database, so a merge is not needed.
				continue;
			}

			// Get the item previously loaded.
			$item = $items[ $item_full_uuid ];

			foreach ( $field_content as $full_field_name_without_db_prefix => $lines ) {
				// Dev note: we rebuild the field content using \n (LF) as line endings.
				// This is because the content is split using \n by the Differ.
				$item['fields'][ $full_field_name_without_db_prefix ] = implode( "\n", $lines );
			}

			// Reverse the values processed by the PHP serialized processor.
			foreach ( $item['fields'] as $full_field_name_without_db_prefix => &$field_value ) {
				if ( ! in_array( $full_field_name_without_db_prefix, ( $uses_php_serialized_processor[ $item_full_uuid ] ?? array() ), true ) ) {
					continue;
				}

				// Convert the diff version to PHP serialized.
				// When this fails, it means that the merge would create a PHP serialized value that is invalid,
				// or that the user tried to inject an unauthorized PHP serialized value.
				// In both cases, we stop the execution of the merge.
				// This will prevent the item to be saved, and the user will have to redo the merge.
				$field_value = PhpSerializedProcessor::convertDiffVersionToPhpSerialized( $field_value );
			}

			// All the mergeable fields were updated.
			// Now, we set the merge date to the same value as the "local_item" last modification date.
			// I.e. if we import into the DEV instance, it will be set to the DEV DB last modification date;
			// if we import into the PROD instance, it will be set to the PROD DB last modification date;
			// if we export from the DEV instance, it will be set to the PROD DB last modification date;
			// if we export from the PROD instance... well, there is no merge date to update/set,
			// because exporting from prod does not require any importability check.
			$new_merge_date = $post_complete_metadata['local_item'][ $tracking_manager->getItemTrackerByDeploymentItem( $item )->getTrackingMetaDataTableName( false ) . '.last_modification_date' ];
			$tracking_manager->setMergeDate( $item, $new_merge_date );

			// Save the item.
			$file_manager->saveTrackableItem( $get_params['deployment_bundle'], $item_full_uuid, $item, array( 'update_bundle_content_json' => true ) );

			if ( ( 'export' === ( $get_params['form_context'] ?? null ) ) && ! $tracking_manager->isProductionEnvironment() ) {
				// On development environments, import the item right after the merge
				// when we are in an export context.
				// When we are on dev, it will make things easier to understand for the user
				// if we import the item right after the merge.
				// This way, the file and the dev database will be synchronized, so any changes
				// that appear in the export UI will be consistent whichever source we use
				// for comparison against the prod environment.
				// After the merge, dev DB === file === prod DB. (1)
				//
				// (1) The content may still differ, especially between (dev DB | file) and (prod DB),
				// but due to the merge_date that was set to the last modification date,
				// the system will consider that the content is fixed on the dev side,
				// so the UI will show that the item has been merged and is ready for export.
				// Then, when the user will import the item on prod,
				// the system will find that the last_modification_date fields are different
				// BUT because the [incoming item.merge_date] === [prod item.last_modification_date],
				// the prod environment will import the item without user intervention
				// (if the user has not modified the item on prod after the merge on dev).
				//
				// @TODO Dev note: there is a small chance that the user has modified the values
				//   in the dev database during the merge process. Data loss may occur in this case.
				//   We should perform a comparison between the meta dates that are in the dev database
				//   and the meta dates of the merge UI to check if we are not overwriting
				//   the dev database with an older version of the item.
				$importer->importItems( array( $item_full_uuid => $item ), true );
			}
		}
	}
}
