<?php
namespace PowerpackElements\Classes;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Reinstall a previously released version of this plugin.
 *
 * The store decides which packages a licence may download; this class decides
 * whether the site is in a state where replacing the plugin directory is safe,
 * and never accepts a download URL from the browser. A request names a package
 * by key, and the URL behind that key is resolved from the store's own
 * response — the one design rule this feature cannot bend, because a rollback
 * is "unpack this zip over wp-content/plugins" by definition.
 *
 * @since 3.0.0
 */
final class PP_Rollback {

	/**
	 * How many packages to ask the store for.
	 *
	 * @since 3.0.0
	 * @var int
	 */
	const VERSION_LIMIT = 5;

	/**
	 * Packages this licence can roll back to.
	 *
	 * @since 3.0.0
	 * @param bool $force Bypass the cached list.
	 * @return array|\WP_Error
	 */
	public static function get_files( $force = false ) {
		if ( ! function_exists( 'pp_elements_get_rollback_versions' ) ) {
			return new \WP_Error(
				'pp_rollback_unavailable',
				esc_html__( 'The update server client is not available.', 'powerpack' )
			);
		}

		$files = pp_elements_get_rollback_versions( self::VERSION_LIMIT, $force );

		if ( is_wp_error( $files ) ) {
			return $files;
		}

		return is_array( $files ) ? $files : [];
	}

	/**
	 * Whether this site may run a rollback at all.
	 *
	 * Separate from the capability check on the route: these are conditions of
	 * the install rather than of the user, and the screen shows the reason
	 * instead of simply hiding the control.
	 *
	 * @since 3.0.0
	 * @return true|\WP_Error
	 */
	public static function can_run() {
		if ( ! current_user_can( 'update_plugins' ) ) {
			return new \WP_Error(
				'pp_rollback_forbidden',
				esc_html__( 'You do not have permission to roll back plugins on this site.', 'powerpack' )
			);
		}

		/*
		 * A rollback of a network activated plugin changes every site on the
		 * network, so it belongs to whoever administers the network — not to a
		 * single site administrator who happens to have update_plugins.
		 */
		if ( self::is_network_activated() && ! current_user_can( 'manage_network_plugins' ) ) {
			return new \WP_Error(
				'pp_rollback_network',
				esc_html__( 'PowerPack is activated for the whole network, so only a network administrator can roll it back.', 'powerpack' )
			);
		}

		if ( defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS ) {
			return new \WP_Error(
				'pp_rollback_file_mods_disabled',
				esc_html__( 'Plugin installs are disabled on this site (DISALLOW_FILE_MODS), so PowerPack cannot be rolled back from here.', 'powerpack' )
			);
		}

		return true;
	}

	/**
	 * Roll back to the package identified by $file_key.
	 *
	 * @since 3.0.0
	 * @param string $file_key Key from the list returned by get_files().
	 * @return array|\WP_Error
	 */
	public static function run( $file_key ) {
		$allowed = self::can_run();

		if ( is_wp_error( $allowed ) ) {
			return $allowed;
		}

		$file_key = sanitize_text_field( (string) $file_key );

		if ( '' === $file_key ) {
			return new \WP_Error(
				'pp_rollback_no_selection',
				esc_html__( 'Please select a version to roll back to.', 'powerpack' )
			);
		}

		/*
		 * Fetched fresh, because a download URL the store signed hours ago may
		 * no longer be redeemable. A failed refresh falls back to the cached
		 * list rather than aborting: a stale URL that still works is better
		 * than no rollback at all when the store is briefly unreachable.
		 */
		$files = self::get_files( true );

		if ( is_wp_error( $files ) ) {
			$cached = self::get_files( false );

			if ( is_wp_error( $cached ) ) {
				return $files;
			}

			$files = $cached;
		}

		$selected = null;

		foreach ( $files as $file ) {
			if ( isset( $file['file_key'] ) && hash_equals( (string) $file['file_key'], $file_key ) ) {
				$selected = $file;
				break;
			}
		}

		if ( empty( $selected ) || empty( $selected['package'] ) ) {
			return new \WP_Error(
				'pp_rollback_package_missing',
				esc_html__( 'That version is no longer available for your license.', 'powerpack' )
			);
		}

		$result = self::perform( $selected['package'] );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		$auto_update_disabled = self::disable_auto_update();

		return [
			'rolledBack'         => true,
			'name'               => isset( $selected['name'] ) ? $selected['name'] : '',
			'version'            => isset( $selected['version'] ) ? $selected['version'] : '',
			'autoUpdateDisabled' => $auto_update_disabled,
		];
	}

	/**
	 * Download a package and unpack it over the plugin directory.
	 *
	 * @since 3.0.0
	 * @param string $package_url Download URL, already known to be the store's.
	 * @return true|\WP_Error
	 */
	private static function perform( $package_url ) {
		$package_url = esc_url_raw( $package_url );

		if ( empty( $package_url ) || ! wp_http_validate_url( $package_url ) ) {
			return new \WP_Error(
				'pp_rollback_invalid_package_url',
				esc_html__( 'The rollback package URL is invalid.', 'powerpack' )
			);
		}

		/*
		 * Checked again here rather than trusted from the caller. This is the
		 * last point before the URL is handed to the upgrader, and the check is
		 * cheap; a future caller that skips the list should not be able to
		 * install a zip from anywhere.
		 */
		if ( ! pp_elements_is_store_url( $package_url ) ) {
			return new \WP_Error(
				'pp_rollback_untrusted_package',
				esc_html__( 'The rollback package is not hosted on the PowerPack update server.', 'powerpack' )
			);
		}

		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/misc.php';
		require_once ABSPATH . 'wp-admin/includes/plugin.php';
		require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';

		$plugin       = POWERPACK_ELEMENTS_BASE;
		$was_active   = is_plugin_active( $plugin );
		$network_wide = self::is_network_activated();

		$upgrader = new \Plugin_Upgrader( new \Automatic_Upgrader_Skin() );
		$upgrader->init();
		$upgrader->upgrade_strings();

		/*
		 * Both filters are added by Plugin_Upgrader::upgrade(), which is not the
		 * entry point here: run() is, because the package comes from our store
		 * rather than from the update transient. Without them nothing would
		 * check that the downloaded zip is a plugin at all, and a zip whose
		 * folder is named anything other than the installed directory would be
		 * installed alongside PowerPack instead of replacing it.
		 */
		add_filter( 'upgrader_source_selection', [ $upgrader, 'check_package' ] );
		add_filter( 'upgrader_source_selection', [ __CLASS__, 'normalize_source_folder' ], 11, 4 );

		$result = $upgrader->run(
			[
				'package'           => $package_url,
				'destination'       => WP_PLUGIN_DIR,
				'clear_destination' => true,
				'clear_working'     => true,
				'hook_extra'        => [
					'plugin' => $plugin,
					'type'   => 'plugin',
					'action' => 'update',

					/*
					 * WordPress 6.3 and later move the current directory aside
					 * before unpacking and put it back if anything fails, which
					 * is the difference between a failed rollback and a site
					 * with no PowerPack directory at all. Older versions ignore
					 * the key.
					 */
					'temp_backup' => [
						'slug' => dirname( $plugin ),
						'src'  => WP_PLUGIN_DIR,
						'dir'  => 'plugins',
					],
				],
			]
		);

		remove_filter( 'upgrader_source_selection', [ $upgrader, 'check_package' ] );
		remove_filter( 'upgrader_source_selection', [ __CLASS__, 'normalize_source_folder' ], 11 );

		if ( is_wp_error( $result ) ) {
			return self::normalize_error( $result );
		}

		if ( false === $result ) {
			if ( is_wp_error( $upgrader->skin->result ) ) {
				return self::normalize_error( $upgrader->skin->result );
			}

			return new \WP_Error(
				'pp_rollback_failed',
				esc_html__( 'Rollback failed. WordPress could not install the selected package.', 'powerpack' )
			);
		}

		/*
		 * An update leaves the plugin in active_plugins, so this is normally a
		 * no-op. It is kept for what it validates: if the package unpacked into
		 * something that is not a loadable plugin, this is where that surfaces,
		 * rather than as a white screen on the next request.
		 */
		if ( $was_active ) {
			$activate = activate_plugin( $plugin, '', $network_wide, true );

			if ( is_wp_error( $activate ) ) {
				return $activate;
			}
		}

		delete_site_transient( 'update_plugins' );
		wp_clean_plugins_cache( true );

		return true;
	}

	/**
	 * Give the unpacked folder the name the plugin is installed under.
	 *
	 * The destination is derived from the folder inside the zip, so a package
	 * built as "powerpack-elements-pro" would install beside the running plugin
	 * rather than over it, leaving two copies and no rollback.
	 *
	 * @since 3.0.0
	 * @param string       $source        Working directory the package unpacked into.
	 * @param string       $remote_source Parent of $source.
	 * @param \WP_Upgrader $upgrader      Upgrader running the install.
	 * @param array        $hook_extra    Context passed to run().
	 * @return string|\WP_Error
	 */
	public static function normalize_source_folder( $source, $remote_source, $upgrader = null, $hook_extra = [] ) {
		global $wp_filesystem;

		if ( ! is_array( $hook_extra ) || empty( $hook_extra['plugin'] ) || POWERPACK_ELEMENTS_BASE !== $hook_extra['plugin'] ) {
			return $source;
		}

		if ( ! $wp_filesystem instanceof \WP_Filesystem_Base ) {
			return $source;
		}

		$expected = dirname( POWERPACK_ELEMENTS_BASE );
		$current  = basename( untrailingslashit( $source ) );

		if ( $expected === $current || ! $wp_filesystem->is_dir( $source ) ) {
			return $source;
		}

		$corrected = trailingslashit( $remote_source ) . $expected;

		if ( ! $wp_filesystem->move( untrailingslashit( $source ), untrailingslashit( $corrected ) ) ) {
			return new \WP_Error(
				'pp_rollback_rename_failed',
				esc_html__( 'The rollback package could not be prepared for installation.', 'powerpack' )
			);
		}

		return trailingslashit( $corrected );
	}

	/**
	 * Stop WordPress from updating straight back to the latest release.
	 *
	 * A rollback with automatic updates left on is undone by the next cron run,
	 * silently. Turning it off is the only reading of "roll back to this
	 * version" that survives the day.
	 *
	 * @since 3.0.0
	 * @return bool Whether the setting was changed.
	 */
	private static function disable_auto_update() {
		$enabled = (array) get_site_option( 'auto_update_plugins', [] );

		if ( ! in_array( POWERPACK_ELEMENTS_BASE, $enabled, true ) ) {
			return false;
		}

		update_site_option(
			'auto_update_plugins',
			array_values( array_diff( $enabled, [ POWERPACK_ELEMENTS_BASE ] ) )
		);

		return true;
	}

	/**
	 * Whether this plugin is active across the network.
	 *
	 * @since 3.0.0
	 * @return bool
	 */
	private static function is_network_activated() {
		if ( ! is_multisite() ) {
			return false;
		}

		if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		return is_plugin_active_for_network( POWERPACK_ELEMENTS_BASE );
	}

	/**
	 * Turn the upgrader's generic failures into something actionable.
	 *
	 * @since 3.0.0
	 * @param \WP_Error $error Upgrader error.
	 * @return \WP_Error
	 */
	private static function normalize_error( $error ) {
		if ( ! is_wp_error( $error ) ) {
			return $error;
		}

		$code = $error->get_error_code();

		if ( 'fs_unavailable' === $code || 'fs_error' === $code ) {
			return new \WP_Error(
				'pp_rollback_filesystem',
				esc_html__( 'WordPress cannot write to the plugins directory on this host, so the rollback could not run. Installing the older version manually over FTP is the alternative.', 'powerpack' )
			);
		}

		if ( 'download_failed' !== $code ) {
			return $error;
		}

		$detail = $error->get_error_data();

		if ( is_string( $detail ) && '' !== trim( $detail ) ) {
			return new \WP_Error(
				'pp_rollback_download_failed',
				sprintf(
					/* translators: %s: underlying download error. */
					esc_html__( 'The rollback package could not be downloaded: %s', 'powerpack' ),
					$detail
				)
			);
		}

		return new \WP_Error(
			'pp_rollback_download_failed',
			esc_html__( 'The rollback package could not be downloaded. Check that your license is active for this site and try again.', 'powerpack' )
		);
	}
}
