<?php
/**
 * Handle the plugin update checks and notifications.
 *
 * @internal This class is for internal use only. It may change in the future without warning.
 *
 * Code is inspired by and adapted from WooCommerce's WC_Plugin_Updates class.
 * @see https://github.com/woocommerce/woocommerce/blob/5f04212f8188e0f7b09f6375d1a6c610fac8a631/plugins/woocommerce/includes/admin/plugin-updates/class-wc-plugin-updates.php
 *
 * @package WPGraphQL\Admin\Updates
 */

namespace WPGraphQL\Admin\Updates;

/**
 * Class UpdateChecker
 *
 * @internal This class is for internal use only. It may change in the future without warning.
 */
class UpdateChecker {
	/**
	 * The version header to check for in the plugin file.
	 */
	public const VERSION_HEADER = 'Requires WPGraphQL';

	/**
	 * The tested up to header to check for in the plugin file.
	 */
	public const TESTED_UP_TO_HEADER = 'WPGraphQL tested up to';

	/**
	 * The current version of the plugin.
	 *
	 * @var string
	 */
	public $current_version = WPGRAPHQL_VERSION;

	/**
	 * The new version of the available.
	 *
	 * @var string
	 */
	public $new_version;

	/**
	 * The local cache of _all_ plugins.
	 *
	 * @var ?array<string,array<string,mixed>>
	 */
	private $all_plugins;

	/**
	 * The array of plugins that use WPGraphQL as a dependency.
	 *
	 * @var ?array<string,array<string,mixed>>
	 */
	private $dependents;

	/**
	 * The array of plugins that *maybe* use WPGraphQL as a dependency.
	 *
	 * @var ?array<string,array<string,mixed>>
	 */
	private $possible_dependents;

	/**
	 * The WPGraphQL plugin data object.
	 *
	 * @var object
	 */
	private $plugin_data;


	/**
	 * The release type of the new version of WPGraphQL.
	 *
	 * @var 'major'|'minor'|'patch'|'prerelease'|'unknown'
	 */
	private $release_type;

	/**
	 * UpdateChecker constructor.
	 *
	 * @param object $plugin_data The plugin data object from the update check.
	 */
	public function __construct( $plugin_data ) {
		$this->plugin_data  = $plugin_data;
		$this->new_version  = property_exists( $plugin_data, 'new_version' ) ? $plugin_data->new_version : '';
		$this->release_type = SemVer::get_release_type( $this->current_version, $this->new_version );
	}

	/**
	 * Checks whether any untested or incompatible WPGraphQL extensions should prevent an autoupdate.
	 *
	 * @param bool $default_value Whether to allow the update by default.
	 */
	public function should_autoupdate( bool $default_value ): bool {
		// If this is a major release, and we have those disabled, don't allow the autoupdate.
		if ( 'major' === $this->release_type && ! $this->should_allow_major_autoupdates() ) {
			return false;
		}

		// If there are active incompatible plugins, don't allow the update.
		$incompatible_plugins = $this->get_incompatible_plugins( $this->new_version, true );

		if ( ! empty( $incompatible_plugins ) ) {
			return false;
		}

		// If allow untested autoupdates enabled, allow the update.
		if ( $this->should_allow_untested_autoupdates() ) {
			return $default_value;
		}

		$untested_release_type = $this->get_untested_release_type();
		$untested_plugins      = $this->get_untested_plugins( $untested_release_type );

		if ( ! empty( $untested_plugins ) ) {
			return false;
		}

		return $default_value;
	}
	/**
	 * Gets a list of plugins that use WPGraphQL as a dependency and are not tested with the current version of WPGraphQL.
	 *
	 * @param string $release_type The release type of the current version of WPGraphQL.
	 *
	 * @return array<string,array<string,mixed>> The array of untested plugin data.
	 * @throws \InvalidArgumentException If the WPGraphQL version is invalid.
	 */
	public function get_untested_plugins( string $release_type ): array {
		$version = SemVer::parse( $this->new_version );

		if ( null === $version ) {
			throw new \InvalidArgumentException( esc_html__( 'Invalid WPGraphQL version', 'wp-graphql' ) );
		}

		$dependents = array_merge(
			$this->get_dependents(),
			$this->get_possible_dependents()
		);

		$untested_plugins = [];
		foreach ( $dependents as $file => $plugin ) {
			// If the plugin doesn't have a version header, it's compatibility is unknown.
			if ( empty( $plugin[ self::TESTED_UP_TO_HEADER ] ) ) {
				$plugin[ self::TESTED_UP_TO_HEADER ] = __( 'Unknown', 'wp-graphql' );

				$untested_plugins[ $file ] = $plugin;
				continue;
			}

			// Parse the tested version.
			$tested_version = SemVer::parse( $plugin[ self::TESTED_UP_TO_HEADER ] );
			if ( null === $tested_version ) {
				continue;
			}

			// If the major version is greater, the plugin is untested.
			if ( $version['major'] > $tested_version['major'] ) {
				$untested_plugins[ $file ] = $plugin;
				continue;
			}

			// If the minor version is greater, the plugin is untested.
			if ( 'major' !== $release_type && $version['minor'] > $tested_version['minor'] ) {
				$untested_plugins[ $file ] = $plugin;
				continue;
			}

			// If the patch version is greater, the plugin is untested.
			if ( 'major' !== $release_type && 'minor' !== $release_type && $version['patch'] > $tested_version['patch'] ) {
				$untested_plugins[ $file ] = $plugin;
				continue;
			}
		}

		return $untested_plugins;
	}

	/**
	 * Get incompatible plugins.
	 *
	 * @param string $version The current plugin version.
	 * @param bool   $active_only Whether to only return active plugins. Default false.
	 *
	 * @return array<string,array<string,mixed>> The array of incompatible plugins.
	 */
	public function get_incompatible_plugins( string $version = WPGRAPHQL_VERSION, bool $active_only = false ): array {
		$dependents = $this->get_dependents();
		$plugins    = [];

		foreach ( $dependents as $file => $plugin ) {
			// Skip if the plugin is not active or is not incompatible.
			if ( ! $this->is_incompatible_dependent( $file, $version ) ) {
				continue;
			}

			// If we only want active plugins, skip if the plugin is not active.
			if ( $active_only && ! is_plugin_active( $file ) ) {
				continue;
			}

			$plugins[ $file ] = $plugin;
		}

		return $plugins;
	}

	/**
	 * Get the shared modal HTML for the update checkers.
	 *
	 * @param array<string,array<string,mixed>> $untested_plugins The untested plugins.
	 */
	public function get_untested_plugins_modal( array $untested_plugins ): string {
		$plugins = array_map(
			static function ( $plugin ) {
				return $plugin['Name'];
			},
			$untested_plugins
		);

		if ( empty( $plugins ) ) {
			return '';
		}

		ob_start();
		?>

		<div id="wp-graphql-update-modal">
			<div class="wp-graphql-update-modal__content">

				<h1><?php esc_html_e( 'Are you sure you\'re ready to update?', 'wp-graphql' ); ?></h1>

				<div class="wp-graphql-update-notice">
					<?php echo wp_kses_post( $this->get_compatibility_warning_message( $untested_plugins ) ); ?>

					<?php if ( current_user_can( 'update_plugins' ) ) : ?>
						<div class="actions">
							<a href="#" class="button button-secondary cancel"><?php esc_html_e( 'Cancel', 'wp-graphql' ); ?></a>
							<a class="button button-primary accept" href="#"><?php esc_html_e( 'Update now', 'wp-graphql' ); ?></a>
						</div>
					<?php endif ?>
				</div>
			</div>
		</div>

		<?php
		return (string) ob_get_clean();
	}

	/**
	 * Outputs the shared modal JS for the update checkers.
	 *
	 * @todo WIP.
	 */
	public function modal_js(): void {
		?>
		<script>
			( function( $ ) {
				// Initialize thickbox.
				tb_init( '.wp-graphql-thickbox' );

				var old_tb_position = false;

				// Make the WC thickboxes look good when opened.
				$( '.wp-graphql-thickbox' ).on( 'click', function( evt ) {
					var $overlay = $( '#TB_overlay' );
					if ( ! $overlay.length ) {
						$( 'body' ).append( '<div id="TB_overlay"></div><div id="TB_window" class="wp-graphql-update-modal__container"></div>' );
					} else {
						$( '#TB_window' ).removeClass( 'thickbox-loading' ).addClass( 'wp-graphql-update-modal__container' );
					}

					// WP overrides the tb_position function. We need to use a different tb_position function than that one.
					// This is based on the original tb_position.
					if ( ! old_tb_position ) {
						old_tb_position = tb_position;
					}
					tb_position = function() {
						$( '#TB_window' ).css( { marginLeft: '-' + parseInt( ( TB_WIDTH / 2 ), 10 ) + 'px', width: TB_WIDTH + 'px' } );
						$( '#TB_window' ).css( { marginTop: '-' + parseInt( ( TB_HEIGHT / 2 ), 10 ) + 'px' } );
					};
				});

				// Reset tb_position to WP default when modal is closed.
				$( 'body' ).on( 'thickbox:removed', function() {
					if ( old_tb_position ) {
						tb_position = old_tb_position;
					}
				});
			}
		) ( jQuery );
		</script>
		<?php
	}

	/**
	 * Returns whether to allow major plugin autoupdates.
	 *
	 * Defaults to false.
	 *
	 * @uses 'wpgraphql_enable_major_autoupdates' filter.
	 */
	protected function should_allow_major_autoupdates(): bool {
		/**
		 * Filter whether to allow major autoupdates.
		 *
		 * @param bool   $should_allow    Whether to allow major autoupdates. Defaults to false.
		 * @param string $new_version     The new WPGraphQL version number.
		 * @param string $current_version The current WPGraphQL version number.
		 * @param object $plugin_data     The plugin data object.
		 */
		return apply_filters( 'wpgraphql_enable_major_autoupdates', false, $this->new_version, $this->current_version, $this->plugin_data );
	}

	/**
	 * Returns whether to allow plugin autoupdates when plugin dependencies are untested and might be incompatible.
	 *
	 * @uses `wpgraphql_untested_release_type` filter to determine the release type to use when checking for untested plugins.
	 *
	 * @uses 'wpgraphql_enable_untested_autoupdates' filter.
	 */
	protected function should_allow_untested_autoupdates(): bool {
		$should_allow = $this->get_untested_release_type() !== $this->release_type;

		/**
		 * Filter whether to allow autoupdates with untested plugins.
		 *
		 * @param bool   $should_allow    Whether to allow autoupdates with untested plugins.
		 * @param string $release_type    The release type of the current version of WPGraphQL. Either 'major', 'minor', 'patch', or 'prerelease'.
		 * @param string $new_version     The new WPGraphQL version number.
		 * @param string $current_version The current WPGraphQL version number.
		 * @param object $plugin_data     The plugin data object.
		 */
		return apply_filters( 'wpgraphql_enable_untested_autoupdates', $should_allow, $this->release_type, $this->new_version, $this->current_version, $this->plugin_data );
	}

	/**
	 * Gets the release type to use when checking for untested plugins.
	 *
	 * @return 'major'|'minor'|'patch'|'prerelease' The release type to use when checking for untested plugins.
	 */
	protected function get_untested_release_type(): string {
		/**
		 * Filter the release type to use when checking for untested plugins.
		 * This is used to prevent autoupdates when a plugin is untested with the specified channel. I.e. major > minor > patch > prerelease.
		 *
		 * @param 'major'|'minor'|'patch'|'prerelease' $release_type The release type to use when checking for untested plugins. Defaults to 'major'.
		 */
		$release_type = (string) apply_filters( 'wpgraphql_untested_release_type', 'major' );

		if ( ! in_array( $release_type, [ 'major', 'minor', 'patch', 'prerelease' ], true ) ) {
			$release_type = 'major';
		}

		return $release_type;
	}
	/**
	 * Gets the plugins that use WPGraphQL as a dependency.
	 *
	 * @return array<string,array<string,mixed>> The array of plugins that use WPGraphQL as a dependency, keyed by plugin path.
	 */
	protected function get_dependents(): array {
		if ( isset( $this->dependents ) ) {
			return $this->dependents;
		}

		$all_plugins = $this->get_all_plugins();
		$plugins     = [];

		foreach ( $all_plugins as $plugin_path => $plugin ) {
			// If they're explicitly using a header, it's a dependent.
			if ( ! $this->is_versioned_dependent( $plugin_path ) && ! $this->is_wpapi_dependent( $plugin_path ) ) {
				continue;
			}

			$plugins[ $plugin_path ] = $plugin;
		}

		/**
		 * Filters the list of plugins that use WPGraphQL as a dependency.
		 *
		 * @param array<string,array<string,mixed>> $plugins The array of plugins that use WPGraphQL as a dependency.
		 * @param array<string,array<string,mixed>> $all_plugins The array of all plugins.
		 */
		$this->dependents = apply_filters( 'graphql_get_dependents', $plugins, $all_plugins );

		return $this->dependents;
	}

	/**
	 * Gets the plugins that *maybe* use WPGraphQL as a dependency.
	 *
	 * @return array<string,array<string,mixed>> The array of plugins that maybe use WPGraphQL as a dependency, keyed by plugin path.
	 */
	protected function get_possible_dependents(): array {
		// Bail early if we've already fetched the possible plugins.
		if ( isset( $this->possible_dependents ) ) {
			return $this->possible_dependents;
		}

		$all_plugins = $this->get_all_plugins();
		$plugins     = [];

		foreach ( $all_plugins as $plugin_path => $plugin ) {
			// Skip the WPGraphQL plugin.
			if ( 'WPGraphQL' === $plugin['Name'] ) {
				continue;
			}

			if ( ! $this->is_possible_dependent( $plugin_path ) ) {
				continue;
			}

			$plugins[ $plugin_path ] = $plugin;
		}

		/**
		 * Filters the list of plugins that use WPGraphQL as a dependency.
		 *
		 * Can be used to hide false positives or to add additional plugins that may use WPGraphQL as a dependency.
		 *
		 * @param array<string,array<string,mixed>> $plugins The array of plugins that maybe use WPGraphQL as a dependency.
		 * @param array<string,array<string,mixed>> $all_plugins The array of all plugins.
		 */
		$this->possible_dependents = apply_filters( 'graphql_get_possible_dependents', $plugins, $all_plugins );

		return $this->possible_dependents;
	}

	/**
	 * Gets all plugins, priming the cache if necessary.
	 *
	 * @return array<string,array<string,mixed>> The array of all plugins, keyed by plugin path.
	 */
	private function get_all_plugins(): array {
		if ( ! isset( $this->all_plugins ) ) {
			$this->all_plugins = get_plugins();
		}

		return $this->all_plugins;
	}

	/**
	 * Checks whether a dependency is incompatible with a specific version of WPGraphQL.
	 *
	 * @param string $plugin_path The plugin path used as the key in the plugins array.
	 * @param string $version     The current version to check against.
	 */
	private function is_incompatible_dependent( string $plugin_path, string $version = WPGRAPHQL_VERSION ): bool {
		$current_version = SemVer::parse( $version );

		if ( null === $current_version ) {
			return false;
		}

		$all_plugins = $this->get_all_plugins();
		$plugin_data = $all_plugins[ $plugin_path ] ?? null;

		// If the plugin doesn't have a version header, it's compatibility is unknown.
		if ( empty( $plugin_data[ self::VERSION_HEADER ] ) ) {
			return false;
		}

		// Parse the version.
		$minimum_version = SemVer::parse( $plugin_data[ self::VERSION_HEADER ] );

		if ( null === $minimum_version ) {
			return false;
		}

		// Check if the plugin is incompatible.
		if ( $minimum_version['major'] > $current_version['major'] ) {
			return true;
		}

		if ( $minimum_version['minor'] > $current_version['minor'] ) {
			return true;
		}

		if ( $minimum_version['patch'] > $current_version['patch'] ) {
			return true;
		}

		// The plugin is compatible.
		return false;
	}

	/**
	 * Checks whether the plugin is "possibly" using WPGraphQL as a dependency.
	 *
	 * I.e if it's in the plugin name or description.
	 *
	 * @param string $plugin_path The plugin path used as the key in the plugins array.
	 */
	private function is_possible_dependent( string $plugin_path ): bool {
		$all_plugins = $this->get_all_plugins();
		$plugin_data = $all_plugins[ $plugin_path ] ?? null;

		// Bail early if the plugin doesn't exist.
		if ( empty( $plugin_data ) ) {
			return false;
		}

		return stristr( $plugin_data['Name'], 'WPGraphQL' ) || stristr( $plugin_data['Description'], 'WPGraphQL' );
	}

	/**
	 * Checks whether the plugin uses our version headers.
	 *
	 * @param string $plugin_path The plugin path used as the key in the plugins array.
	 */
	private function is_versioned_dependent( string $plugin_path ): bool {
		$all_plugins = $this->get_all_plugins();
		$plugin_data = $all_plugins[ $plugin_path ] ?? null;

		// Bail early if the plugin doesn't exist.
		if ( empty( $plugin_data ) ) {
			return false;
		}

		return ! empty( $plugin_data[ self::VERSION_HEADER ] ) || ! empty( $plugin_data[ self::TESTED_UP_TO_HEADER ] );
	}

	/**
	 * Whether the plugin lists WPGraphQL in its `Requires Plugins` header.
	 *
	 * @param string $plugin_path The plugin path used as the key in the plugins array.
	 */
	private function is_wpapi_dependent( string $plugin_path ): bool {
		$all_plugins = $this->get_all_plugins();

		$plugin_data = $all_plugins[ $plugin_path ] ?? null;

		// Bail early if the plugin doesn't exist.
		if ( empty( $plugin_data ) || empty( $plugin_data['RequiresPlugins'] ) ) {
			return false;
		}

		// Normalize the required plugins.
		$required_plugins = array_map(
			static function ( $slug ) {
				return strtolower( trim( $slug ) );
			},
			explode( ',', $plugin_data['RequiresPlugins'] ) ?: []
		);

		return in_array( 'wp-graphql', $required_plugins, true );
	}

	/**
	 * Gets the complete compatibility warning message including the plugins table and follow-up text.
	 *
	 * @param array<string,array<string,mixed>> $untested_plugins The untested plugins.
	 * @return string The formatted HTML message.
	 */
	public function get_compatibility_warning_message( array $untested_plugins ): string {
		ob_start();
		?>
		<p>
		<?php
		echo wp_kses_post(
			sprintf(
			// translators: %s: The WPGraphQL version wrapped in a strong tag.
				__(
					'The following active plugin(s) require WPGraphQL to function but have not yet declared compatibility with %s. Before updating WPGraphQL, please:',
					'wp-graphql'
				),
				// translators: %s: The WPGraphQL version.
				sprintf( '<strong>WPGraphQL v%s</strong>', $this->new_version )
			)
		);
		?>
		</p>

		<ol>
			<li>
			<?php
			echo wp_kses_post(
				sprintf(
				// translators: %s: The WPGraphQL version wrapped in a strong tag.
					__( 'Update these plugins to their latest versions that declare compatibility with %s, OR', 'wp-graphql' ),
					sprintf( '<strong>WPGraphQL v%s</strong>', $this->new_version )
				)
			);
			?>
			</li>
			<li>
			<?php
			echo wp_kses_post(
				sprintf(
				// translators: %s: The WPGraphQL version wrapped in a strong tag.
					__( 'Confirm their compatibility with %s on your staging environment.', 'wp-graphql' ),
					sprintf( '<strong>WPGraphQL v%s</strong>', $this->new_version )
				)
			);
			?>
			</li>
		</ol>

		<div class="plugin-details-table-container">
			<table class="plugin-details-table" cellspacing="0">
				<thead>
					<tr>
						<th><?php esc_html_e( 'Plugin', 'wp-graphql' ); ?></th>
						<th><?php esc_html_e( 'WPGraphQL Tested Up To', 'wp-graphql' ); ?></th>
					</tr>
				</thead>
				<tbody>
					<?php foreach ( $untested_plugins as $plugin ) : ?>
						<tr>
							<td><?php echo esc_html( $plugin['Name'] ); ?></td>
							<td><?php echo esc_html( $plugin[ self::TESTED_UP_TO_HEADER ] ); ?></td>
						</tr>
					<?php endforeach; ?>
				</tbody>
			</table>
		</div>

		<p><?php esc_html_e( 'For more information, review each plugin\'s changelogs or contact the plugin\'s developers.', 'wp-graphql' ); ?></p>

		<p><strong><?php esc_html_e( 'We strongly recommend creating a backup of your site before updating.', 'wp-graphql' ); ?></strong></p>
		<?php
		return (string) ob_get_clean();
	}
}
