<?php
/**
 * UUID generator for the "posts" tracker.
 *
 * @package Ealab\Deployer
 */

namespace EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\UuidGenerator;

use EaLab\WP_Deployer\vendor\Dakwamine\Component\ComponentBasedObject;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Exception\WpDeployException;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\ItemTracker\ItemTrackerBase;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\SqlChangesTracker;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\Api;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\Database;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\Environment;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\Settings;
use Throwable;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * UUID generator for the "posts" tracker.
 *
 * Use this only on the "posts" tracker, because it works only for 1 tracker
 * due to the use of non-shareable properties and specific code.
 *
 * This can be made generic later if ever needed.
 *
 * The objective of this generator is to match items' UUID between the local (dev)
 * instance and the prod instance, especially useful for the case scenario
 * when the plugin has just been activated on the local instance.
 */
class PostsUuidGenerator extends UuidGeneratorBase {
	/**
	 * Option name for the last shared post ID.
	 */
	const OPTION__LAST_SHARED_POST_ID = 'deploy_sync_content__last_shared_post_id';

	/**
	 * Slice length.
	 *
	 * Slice length must be something not too big for a webservice call,
	 * and not too short to limit the number of requests required to find
	 * the result.
	 */
	const SLICE_LENGTH = 10000;

	/**
	 * Last shared post ID.
	 *
	 * Use the getLastMatchingPostIdBetweenDevAndProd() method
	 * instead of getting this property directly.
	 *
	 * @var int|null
	 */
	private ?int $last_shared_post_id = null;

	/**
	 * {@inheritDoc}
	 */
	public function generateHashFromContent( string $local_id, ItemTrackerBase $item_tracker, ?array $options = null ): ?string {
		$unset_post_date_gmt = static function ( &$value ) {
			// Unset the post_date_gmt value. The production instance uses fewer source fields
			// than the local / dev instances.
			unset( $value['post_date_gmt'] );
		};

		if ( Environment::isProductionEnvironment() ) {
			// Whatever the track mode is, we must remove the post_date_gmt;
			// the production environment does not use the post_date_gmt.
			// This callback will remove it.
			$options['value_prehash_callback'] = $unset_post_date_gmt;

			return parent::generateHashFromContent( $local_id, $item_tracker, $options );
		}

		if ( ItemTrackerBase::TRACK_MODE__TRACKER_INIT !== ( $options['track_mode'] ?? ItemTrackerBase::TRACK_MODE__NORMAL ) ) {
			// Default handling.
			return parent::generateHashFromContent( $local_id, $item_tracker, $options );
		}

		// We are not on a production environment, and we explicitly want to track for init.
		// On tracker init, we attempt to generate the UUIDs that are in use on prod.
		// The design here is to find the lastest matching item on both instances local and prod.
		// Then all items with IDs below this latest matching item's ID will be considered as
		// having a production instance origin; thus its UUID will follow the same generation rule
		// as the production instance itself.
		$last_matching_post_id = $this->getLastMatchingPostIdBetweenDevAndProd();

		if ( empty( $last_matching_post_id ) ) {
			// No matching post at all (should nearly be impossible...).
			// Do the usual stuff.
			return parent::generateHashFromContent( $local_id, $item_tracker, $options );
		}

		if ( $local_id > $last_matching_post_id ) {
			// The local item doesn't exist on production (unless very unusual case scenarios
			// when the data has been modified by hand directly on the DB or the DB has corrupted data).
			// Do the usual stuff then.
			return parent::generateHashFromContent( $local_id, $item_tracker, $options );
		}

		// This item to generate its hash is already on production.
		// We must generate its UUID as if we were on the production instance.
		// This callback will remove the post_date_gmt value from the UUID source fields, like prod.
		$options['value_prehash_callback'] = $unset_post_date_gmt;

		return parent::generateHashFromContent( $local_id, $item_tracker, $options );
	}

	/**
	 * Gets the last intersecting item of the given arrays.
	 *
	 * @param array $a First array.
	 * @param array $b Second array.
	 *
	 * @return array|null The intersecting item if found, null otherwise.
	 */
	private function getLastIntersectingItem( array $a, array $b ): ?array {
		$last_a = array_pop( $a );
		$last_b = array_pop( $b );

		while ( true ) {
			if ( empty( $last_a ) || empty( $last_b ) ) {
				// No intersecting item.
				return null;
			}

			if ( $last_a === $last_b ) {
				return $last_a;
			}

			if ( $last_b['ID'] > $last_a['ID'] ) {
				$last_b = array_pop( $b );
			} elseif ( $last_b['ID'] < $last_a['ID'] ) {
				$last_a = array_pop( $a );
			} else {
				$last_a = array_pop( $a );
				$last_b = array_pop( $b );
			}
		}
	}

	/**
	 * Gets the last matching post ID between dev and prod.
	 *
	 * When the content to track (index) has an ID smaller than this post ID,
	 * and when we are in the TRACK_MODE__TRACKER_INIT phase, the
	 *
	 * @return int|null The last matching ID. NULL if there is no matching posts.
	 */
	private function getLastMatchingPostIdBetweenDevAndProd(): ?int {
		if ( ! empty( $this->last_shared_post_id ) ) {
			// The value has already been retrieved once in this session.
			return $this->last_shared_post_id;
		}

		// Check if this value is already computed.
		$this->last_shared_post_id = get_option( static::OPTION__LAST_SHARED_POST_ID, null );

		if ( ! empty( $this->last_shared_post_id ) ) {
			return $this->last_shared_post_id;
		}

		// First, get the last ID from local (dev) of the given tracker,
		// and assign it as the first $last_id__search_range.
		// This represents the last ID of the search range.
		// Any item with greater ID is not a candidate.
		$last_id__search_range = $this->getLocalLastPostId();

		while ( $last_id__search_range > 0 ) {
			/*
			 * Get a fixed width slice of prod content, starting from the last ID.
			 *
			 * The slices have this structure:
			 * - "ID"
			 * - "guid" (without the domain part)
			 * - "post_date_gmt"
			 * - "post_type"
			 */
			$posts_slice__prod = $this->getProdPostsSlice( $last_id__search_range );

			$last_item__prod = end( $posts_slice__prod );

			if ( empty( $last_item__prod ) ) {
				// The prod has no more items to check.
				break;
			}

			$last_id__prod = $last_item__prod['ID'];

			// Update the search range to discard local items with ID > $last_id__prod.
			$last_id__search_range = min( $last_id__search_range, $last_id__prod );

			$posts_slice__local = $this->getLocalPostsSlice( $last_id__search_range );

			$last_item__local = end( $posts_slice__local );

			if ( empty( $last_item__local ) ) {
				// The local has no more items to check.
				break;
			}

			$intersection = $this->getLastIntersectingItem(
				$posts_slice__prod,
				$posts_slice__local
			);

			if ( $intersection ) {
				// The last item is the chosen one.
				// Dev note: this option must be recomputed when the index is totally reset.
				update_option( static::OPTION__LAST_SHARED_POST_ID, $intersection['ID'] );

				$this->last_shared_post_id = $intersection['ID'];

				return $this->last_shared_post_id;
			}

			// No matching item. Check if we should make another query.
			if ( count( $posts_slice__prod ) < static::SLICE_LENGTH && count( $posts_slice__local ) < static::SLICE_LENGTH ) {
				// Both slices have not enough content. It means we reached the end of the inspectable content.
				break;
			}

			// Perform another check with the next slice.
			// Update the range so that the last ID is the
			// greatest of the lowest of the prod & local slices,
			// minus 1 because we do a "<=" in the DB select query.
			$last_id__search_range = max( reset( $posts_slice__prod )['ID'], reset( $posts_slice__local )['ID'] ) - 1;
		}

		// No matching item found.
		return null;
	}

	/**
	 * Gets the last post ID of this instance.
	 *
	 * @return int The post ID. May return 0 if no ID (should never happen though).
	 */
	private function getLocalLastPostId(): int {
		$db = Database::getTrackingDatabaseHandle();

		return (int) ( $db->get_var(
			$db->prepare(
				'SELECT MAX(`ID`) FROM %i;' . SqlChangesTracker::IGNORE_TRACKING,
				array(
					$db->prefix . 'posts',
				)
			)
		) ?? 0 );
	}

	/**
	 * Gets the local posts slice.
	 *
	 * @param int $max_id The max post ID of the returned slice.
	 *
	 * @return array The posts slice. The last post ID may differ from the max post ID.
	 */
	private function getLocalPostsSlice( int $max_id ): array {
		$db = Database::getTrackingDatabaseHandle();

		$query = $db->prepare(
			<<<SQL
			SELECT a.*
			FROM (
				SELECT `ID`, `post_type`, `guid`, `post_date_gmt`
				FROM %i
				WHERE `ID` <= %s
				ORDER BY `ID` DESC
				LIMIT %d
			) a
			ORDER BY a.`ID`;
			SQL. SqlChangesTracker::IGNORE_TRACKING,
			array(
				$db->prefix . 'posts',
				$max_id,
				static::SLICE_LENGTH,
			)
		);

		$results = $db->get_results( $query, ARRAY_A );

		$siteurl = get_option( 'siteurl' );

		array_walk(
			$results,
			static function ( &$item ) use ( $siteurl ) {
				$item['guid'] = str_replace( $siteurl, '', $item['guid'] );
			}
		);

		return $results;
	}

	/**
	 * Gets a production instance posts slice.
	 *
	 * @param int $max_id The max ID of posts to retrieve.
	 *
	 * @return array The retrieved posts. The array may be empty if no more items have been found.
	 *
	 * @throws WpDeployException Deploy & Sync Content exception.
	 * @throws Throwable Any other error.
	 */
	private function getProdPostsSlice( int $max_id ): array {
		if ( Environment::isProductionEnvironment() ) {
			throw new WpDeployException( esc_html( _dgettext( 'deployer-core', 'getProdPostsSlice must not be called from a production instance.' ) ) );
		}

		$url = http_build_query(
			array(
				'max_id' => $max_id,
			)
		);

		$response = Api::callApiProd(
			'GET',
			'wp-json/deploy-sync-content/v1/deployment/env/posts?' . $url,
			null,
			false,
			array( 'use_guzzle' => true )
		);

		if ( $response->getStatusCode() !== 200 ) {
			// Failure.
			throw new WpDeployException( esc_html( _dgettext( 'deployer-core', 'Cannot get production post info. Please check the production instance settings to allow data retrieval.' ) ) );
		}

		return json_decode( $response->getBody()->getContents(), true );
	}

	/**
	 * Registers REST hooks.
	 */
	public static function registerRestHooks(): void {
		add_action(
			'rest_api_init',
			static function () {
				register_rest_route(
					'deploy-sync-content/v1',
					'/deployment/env/posts',
					array(
						array(
							'methods'             => WP_REST_Server::READABLE,
							'callback'            => function ( WP_REST_Request $request ) {
								$max_id = $request->get_param( 'max_id' );

								if ( empty( $max_id ) ) {
									return new WP_REST_Response( array( 'message' => 'Missing parameter.' ), 400 );
								}

								$response    = array();
								$status_code = 200;

								try {
									// It's fine to load the generator as a root component here;
									// we don't use it anymore after sending the response.
									$post_uuid_generator = ComponentBasedObject::getRootComponentByClassName( PostsUuidGenerator::class, true );
									$response            = $post_uuid_generator->getLocalPostsSlice( $max_id );
								} catch ( Throwable $t ) {
									$status_code         = 500;
									$response['message'] = esc_html( $t->getMessage() );
								}

								return new WP_REST_Response( $response, $status_code );
							},
							'permission_callback' => function ( WP_REST_Request $request ) {
								$dsc_settings_data = Settings::getInstance()->getAll();

								return ! empty( $dsc_settings_data['sync_prod'] )
									&& ! empty( $dsc_settings_data['access_token'] )
									&& $request->get_header( 'X-Deployer-Access-Token' ) === $dsc_settings_data['access_token'];
							},
						),
					)
				);
			}
		);
	}

	/**
	 * {@inheritDoc}
	 */
	public function resetTracking( ItemTrackerBase $item_tracker ) {
		// Delete the last shared post ID, because we are maybe changing the production URL,
		// and we would have to check the last shared post again.
		delete_option( static::OPTION__LAST_SHARED_POST_ID );
	}
}
