<?php

namespace WP_CLI;

use Composer\Semver\VersionParser;
use Composer\Semver\Comparator;
use Exception;
use WP_CLI;
use WP_CLI\Fetchers;
use WP_CLI\Loggers;
use WP_CLI\Utils;
use WP_Error;

abstract class CommandWithUpgrade extends \WP_CLI_Command {

	protected $fetcher;
	protected $item_type;
	protected $obj_fields;

	protected $upgrade_refresh;
	protected $upgrade_transient;

	protected $chained_command = false;

	/**
	 * The GitHub Releases public api endpoint.
	 *
	 * @var string
	 */
	private $github_releases_api_endpoint = 'https://api.github.com/repos/%s/releases';

	/**
	 * The GitHub latest release url format.
	 *
	 * @var string
	 */
	private $github_latest_release_url = '/^https:\/\/github\.com\/(.*)\/releases\/latest\/?$/';

	// Invalid version message.
	const INVALID_VERSION_MESSAGE = 'version higher than expected';

	public function __construct() {

		// Do not automatically check translations updates after updating plugins/themes.
		add_action(
			'upgrader_process_complete',
			function () {
				remove_action( 'upgrader_process_complete', [ 'Language_Pack_Upgrader', 'async_upgrade' ], 20 );
			},
			1
		);

		add_filter(
			'http_request_timeout',
			function () {
				return 1 * MINUTE_IN_SECONDS;
			},
			999
		);

		$this->fetcher = new Fetchers\Plugin();
	}

	abstract protected function get_upgrader_class( $force );

	abstract protected function get_item_list();

	/**
	 * @param array List of update candidates
	 * @param array List of item names
	 * @return array List of update candidates
	 */
	abstract protected function filter_item_list( $items, $args );

	abstract protected function get_all_items();

	abstract protected function get_status( $file );

	abstract protected function status_single( $args );

	abstract protected function install_from_repo( $slug, $assoc_args );

	public function status( $args ) {
		// Force WordPress to check for updates.
		call_user_func( $this->upgrade_refresh );

		if ( empty( $args ) ) {
			$this->status_all();
		} else {
			$this->status_single( $args );
		}
	}

	private function status_all() {
		$items = $this->get_all_items();

		$n = count( $items );

		WP_CLI::log(
			sprintf( '%d installed %s:', $n, Utils\pluralize( $this->item_type, absint( $n ) ) )
		);

		$padding = $this->get_padding( $items );

		foreach ( $items as $file => $details ) {
			if ( 'available' === $details['update'] ) {
				$line = ' %yU%n';
			} else {
				$line = '  ';
			}

			$line .= $this->format_status( $details['status'], 'short' );
			$line .= ' ' . str_pad( $details['name'], $padding ) . '%n';
			if ( ! empty( $details['version'] ) ) {
				$line .= ' ' . $details['version'];
			}

			WP_CLI::line( WP_CLI::colorize( $line ) );
		}

		WP_CLI::line();

		$this->show_legend( $items );
	}

	private function get_padding( $items ) {
		$max_len = 0;

		foreach ( $items as $details ) {
			$len = strlen( $details['name'] );

			if ( $len > $max_len ) {
				$max_len = $len;
			}
		}

		return $max_len;
	}

	private function show_legend( $items ) {
		$statuses = array_unique( wp_list_pluck( $items, 'status' ) );

		$legend_line = array();

		foreach ( $statuses as $status ) {
			$legend_line[] = sprintf(
				'%s%s = %s%%n',
				$this->get_color( $status ),
				$this->map['short'][ $status ],
				$this->map['long'][ $status ]
			);
		}
		if ( in_array( 'available', wp_list_pluck( $items, 'update' ), true ) ) {
			$legend_line[] = '%yU = Update Available%n';
		}

		WP_CLI::line( 'Legend: ' . WP_CLI::colorize( implode( ', ', $legend_line ) ) );
	}

	public function install( $args, $assoc_args ) {
		$successes = 0;
		$errors    = 0;
		foreach ( $args as $slug ) {

			if ( empty( $slug ) ) {
				WP_CLI::warning( 'Ignoring ambiguous empty slug value.' );
				continue;
			}

			$result = false;

			$is_remote = false !== strpos( $slug, '://' );

			if ( $is_remote ) {
				$github_repo = $this->get_github_repo_from_releases_url( $slug );

				if ( $github_repo ) {
					$version = $this->get_the_latest_github_version( $github_repo );

					if ( is_wp_error( $version ) ) {
						WP_CLI::error( $version->get_error_message() );
					}

					/**
					 * Sets the $slug that will trigger the installation based on a zip file.
					 */
					$slug = $version['url'];

					WP_CLI::log( 'Latest release resolved to ' . $version['name'] );
				}
			}

			// Check if a URL to a remote or local zip has been specified.
			if ( $is_remote || ( pathinfo( $slug, PATHINFO_EXTENSION ) === 'zip' && is_file( $slug ) ) ) {
				// Install from local or remote zip file.
				$file_upgrader = $this->get_upgrader( $assoc_args );

				$filter = false;
				// If a GitHub URL, do some guessing as to the correct plugin/theme directory.
				if ( $is_remote && 'github.com' === $this->parse_url_host_component( $slug, PHP_URL_HOST )
						// Don't attempt to rename ZIPs uploaded to the releases page or coming from a raw source.
						&& ! preg_match( '#github\.com/[^/]+/[^/]+/(?:releases/download|raw)/#', $slug ) ) {

					$filter = function ( $source ) use ( $slug ) {

						$slug_dir = Utils\basename( $this->parse_url_host_component( $slug, PHP_URL_PATH ), '.zip' );

						// Don't use the zip name if archive attached to release, as name likely to contain version tag/branch.
						if ( preg_match( '#github\.com/[^/]+/([^/]+)/archive/#', $slug, $matches ) ) {
							// Note this will be wrong if the project name isn't the same as the plugin/theme slug name.
							$slug_dir = $matches[1];
						}

						$source_dir = Utils\basename( $source ); // `$source` is trailing-slashed path to the unzipped archive directory, so basename returns the unslashed directory.
						if ( $source_dir === $slug_dir ) {
							return $source;
						}
						$new_path = substr_replace( $source, $slug_dir, strrpos( $source, $source_dir ), strlen( $source_dir ) );

						if ( $GLOBALS['wp_filesystem']->move( $source, $new_path ) ) {
							WP_CLI::log( sprintf( "Renamed Github-based project from '%s' to '%s'.", $source_dir, $slug_dir ) );
							return $new_path;
						}

						return new WP_Error( 'wpcli_install_github', "Couldn't move Github-based project to appropriate directory." );
					};
					add_filter( 'upgrader_source_selection', $filter, 10 );
				}

				// Add item to cache allowlist if it matches certain URL patterns.
				self::maybe_cache( $slug, $this->item_type );

				if ( $file_upgrader->install( $slug ) ) {
					$slug   = $file_upgrader->result['destination_name'];
					$result = true;
					if ( $filter ) {
						remove_filter( 'upgrader_source_selection', $filter, 10 );
					}
					++$successes;
				} else {
					++$errors;
				}
			} else {
				// Assume a plugin/theme slug from the WordPress.org repository has been specified.
				$result = $this->install_from_repo( $slug, $assoc_args );

				if ( is_null( $result ) ) {
					++$errors;
				} elseif ( is_wp_error( $result ) ) {
					$key = $result->get_error_code();
					if ( in_array( $key, [ 'plugins_api_failed', 'themes_api_failed' ], true )
						&& ! empty( $result->error_data[ $key ] ) && in_array( $result->error_data[ $key ], [ 'N;', 'b:0;' ], true ) ) {
						WP_CLI::warning( "Couldn't find '$slug' in the WordPress.org {$this->item_type} directory." );
						++$errors;
					} else {
						WP_CLI::warning( "$slug: " . $result->get_error_message() );
						if ( 'already_installed' !== $key ) {
							++$errors;
						}
					}
				} else {
					++$successes;
				}
			}

			// Check extension is available or not.
			$extension = $this->fetcher->get_many( array( $slug ) );

			// If installation goes well $result will be true.
			$allow_activation = $result;

			// Allow installation for installed extension.
			if ( is_wp_error( $result ) && 'already_installed' === $result->get_error_code() ) {
				$allow_activation = true;
			}

			if ( true === $allow_activation && count( $extension ) > 0 ) {
				$this->chained_command = true;
				if ( Utils\get_flag_value( $assoc_args, 'activate-network' ) ) {
					WP_CLI::log( "Network-activating '$slug'..." );
					$this->activate( array( $slug ), array( 'network' => true ) );
				}

				if ( Utils\get_flag_value( $assoc_args, 'activate' ) ) {
					WP_CLI::log( "Activating '$slug'..." );
					$this->activate( array( $slug ) );
				}
				$this->chained_command = false;
			}
		}
		Utils\report_batch_operation_results( $this->item_type, 'install', count( $args ), $successes, $errors );
	}

	/**
	 * Prepare an API response for downloading a particular version of an item.
	 *
	 * @param object $response wordpress.org API response
	 * @param string $version The desired version of the package
	 */
	protected static function alter_api_response( $response, $version ) {
		if ( $response->version === $version ) {
			return;
		}

		// WordPress.org forces https, but still sometimes returns http
		// See https://twitter.com/nacin/status/512362694205140992
		$response->download_link = str_replace( 'http://', 'https://', $response->download_link );

		list( $link ) = explode( $response->slug, $response->download_link );

		if ( false !== strpos( $response->download_link, '/theme/' ) ) {
			$download_type = 'theme';
		} elseif ( false !== strpos( $response->download_link, '/plugin/' ) ) {
			$download_type = 'plugin';
		} else {
			$download_type = 'plugin/theme';
		}

		if ( 'dev' === $version ) {
			$response->download_link = $link . $response->slug . '.zip';
			$response->version       = 'Development Version';
		} else {
			$response->download_link = $link . $response->slug . '.' . $version . '.zip';
			$response->version       = $version;

			// Check if the requested version exists.
			$response      = wp_remote_head( $response->download_link );
			$response_code = wp_remote_retrieve_response_code( $response );
			if ( 200 !== (int) $response_code ) {
				if ( is_wp_error( $response ) ) {
					$error_msg = $response->get_error_message();
				} else {
					$error_msg = sprintf( 'HTTP code %d', $response_code );
				}
				WP_CLI::error(
					sprintf(
						"Can't find the requested %s's version %s in the WordPress.org %s repository (%s).",
						$download_type,
						$version,
						$download_type,
						$error_msg
					)
				);
			}
		}
	}

	protected function get_upgrader( $assoc_args ) {
		$force          = (bool) Utils\get_flag_value( $assoc_args, 'force', false );
		$insecure       = (bool) Utils\get_flag_value( $assoc_args, 'insecure', false );
		$upgrader_class = $this->get_upgrader_class( $force );
		return Utils\get_upgrader( $upgrader_class, $insecure );
	}

	protected function update_many( $args, $assoc_args ) {
		call_user_func( $this->upgrade_refresh );

		if ( ! empty( $assoc_args['format'] ) && in_array( $assoc_args['format'], [ 'json', 'csv' ], true ) ) {
			$logger = new Loggers\Quiet( WP_CLI::get_runner()->in_color() );
			WP_CLI::set_logger( $logger );
		}

		if ( ! Utils\get_flag_value( $assoc_args, 'all' ) && empty( $args ) ) {
			WP_CLI::error( "Please specify one or more {$this->item_type}s, or use --all." );
		}

		if ( Utils\get_flag_value( $assoc_args, 'minor' ) && Utils\get_flag_value( $assoc_args, 'patch' ) ) {
			WP_CLI::error( '--minor and --patch cannot be used together.' );
		}

		$items = $this->get_item_list();

		$errors  = 0;
		$skipped = 0;
		if ( ! Utils\get_flag_value( $assoc_args, 'all' ) ) {
			$items  = $this->filter_item_list( $items, $args );
			$errors = count( $args ) - count( $items );
		}

		$items_to_update = array_filter(
			$items,
			function ( $item ) {
				return isset( $item['update'] ) && 'none' !== $item['update'];
			}
		);

		$minor = (bool) Utils\get_flag_value( $assoc_args, 'minor', false );
		$patch = (bool) Utils\get_flag_value( $assoc_args, 'patch', false );

		if (
			in_array( $this->item_type, [ 'plugin', 'theme' ], true ) &&
			( $minor || $patch )
		) {
			$type     = $minor ? 'minor' : 'patch';
			$insecure = (bool) Utils\get_flag_value( $assoc_args, 'insecure', false );

			$items_to_update = self::get_minor_or_patch_updates( $items_to_update, $type, $insecure, true, $this->item_type );
		}

		$exclude = Utils\get_flag_value( $assoc_args, 'exclude' );
		if ( isset( $exclude ) ) {
			$exclude_items = explode( ',', trim( $assoc_args['exclude'], ',' ) );
			unset( $assoc_args['exclude'] );
			foreach ( $exclude_items as $item ) {
				if ( 'plugin' === $this->item_type ) {
					$plugin = $this->fetcher->get( $item );
					if ( ! $plugin ) {
						continue;
					}
					unset( $items_to_update[ $plugin->file ] );
				} elseif ( 'theme' === $this->item_type ) {
					$theme = wp_get_theme( $item );
					if ( $theme->exists() ) {
						unset( $items_to_update[ $theme->get_stylesheet() ] );
					}
				}
			}
		}

		// Check for items to update and remove extensions that have version higher than expected.
		foreach ( $items_to_update as $item_key => $item_info ) {
			if ( static::INVALID_VERSION_MESSAGE === $item_info['update'] ) {
				WP_CLI::warning( "{$item_info['name']}: " . static::INVALID_VERSION_MESSAGE . '.' );
				++$skipped;
				unset( $items_to_update[ $item_key ] );
			}
			if ( 'unavailable' === $item_info['update'] ) {
				WP_CLI::warning( "{$item_info['name']}: {$item_info['update_unavailable_reason']}" );
				++$skipped;
				unset( $items_to_update[ $item_key ] );
			}
		}

		if ( Utils\get_flag_value( $assoc_args, 'dry-run' ) ) {
			if ( empty( $items_to_update ) ) {
				WP_CLI::log( "No {$this->item_type} updates available." );

				if ( null !== $exclude ) {
					WP_CLI::log( "Skipped updates for: $exclude" );
				}

				return;
			}

			if ( ! empty( $assoc_args['format'] ) && in_array( $assoc_args['format'], [ 'json', 'csv' ], true ) ) {
				Utils\format_items( $assoc_args['format'], $items_to_update, [ 'name', 'status', 'version', 'update_version' ] );
			} elseif ( ! empty( $assoc_args['format'] ) && 'summary' === $assoc_args['format'] ) {
				WP_CLI::log( "Available {$this->item_type} updates:" );
				foreach ( $items_to_update as $item_to_update => $info ) {
					WP_CLI::log( "{$info['title']} update from version {$info['version']} to version {$info['update_version']}" );
				}
			} else {
				WP_CLI::log( "Available {$this->item_type} updates:" );
				Utils\format_items( 'table', $items_to_update, [ 'name', 'status', 'version', 'update_version' ] );
			}

			if ( null !== $exclude ) {
				WP_CLI::log( "Skipped updates for: $exclude" );
			}

			return;
		}

		$result = array();

		// Only attempt to update if there is something to update.
		if ( ! empty( $items_to_update ) ) {
			$cache_manager = WP_CLI::get_http_cache_manager();
			foreach ( $items_to_update as $item ) {
				$cache_manager->whitelist_package( $item['update_package'], $this->item_type, $item['name'], $item['update_version'] );
			}
			$upgrader = $this->get_upgrader( $assoc_args );
			// Ensure the upgrader uses the download offer present in each item.
			$transient_filter = function ( $transient ) use ( $items_to_update ) {
				foreach ( $items_to_update as $name => $item_data ) {
					if ( isset( $transient->response[ $name ] ) ) {
						if ( is_object( $transient->response[ $name ] ) ) {
							$transient->response[ $name ]->new_version = $item_data['update_version'];
							$transient->response[ $name ]->package     = $item_data['update_package'];
						} else {
							$transient->response[ $name ]['new_version'] = $item_data['update_version'];
							$transient->response[ $name ]['package']     = $item_data['update_package'];
						}
					}
				}
				return $transient;
			};
			add_filter( 'site_transient_' . $this->upgrade_transient, $transient_filter, 999 );
			$result = $upgrader->bulk_upgrade( wp_list_pluck( $items_to_update, 'update_id' ) );
			remove_filter( 'site_transient_' . $this->upgrade_transient, $transient_filter, 999 );
		}

		// Let the user know the results.
		$num_to_update = count( $items_to_update );
		$num_updated   = count(
			array_filter(
				$result,
				static function ( $result ) {
					return $result && ! is_wp_error( $result );
				}
			)
		);

		if ( $num_to_update > 0 ) {
			if ( ! empty( $assoc_args['format'] ) && 'summary' === $assoc_args['format'] ) {
				foreach ( $items_to_update as $item_to_update => $info ) {
					$message = null !== $result[ $info['update_id'] ] ? 'updated successfully' : 'did not update';
					WP_CLI::log( "{$info['title']} {$message} from version {$info['version']} to version {$info['update_version']}" );
				}
			} else {
				$status = array();
				foreach ( $items_to_update as $item_to_update => $info ) {
					$status[ $item_to_update ] = [
						'name'        => $info['name'],
						'old_version' => $info['version'],
						'new_version' => $info['update_version'],
						'status'      => ( null !== $result[ $info['update_id'] ] && ! is_wp_error( $result[ $info['update_id'] ] ) ) ? 'Updated' : 'Error',
					];
					if ( null === $result[ $info['update_id'] ] || is_wp_error( $result[ $info['update_id'] ] ) ) {
						++$errors;
					}
				}

				$format = 'table';
				if ( ! empty( $assoc_args['format'] ) && in_array( $assoc_args['format'], [ 'json', 'csv' ], true ) ) {
					$format = $assoc_args['format'];
				}

				Utils\format_items( $format, $status, [ 'name', 'old_version', 'new_version', 'status' ] );
			}
		}

		$total_updated = Utils\get_flag_value( $assoc_args, 'all' ) ? $num_to_update : count( $args );
		if ( 0 === $num_updated && $skipped ) {
			$errors  = $skipped;
			$skipped = null;
		}
		Utils\report_batch_operation_results( $this->item_type, 'update', $total_updated, $num_updated, $errors, $skipped );
		if ( null !== $exclude ) {
			WP_CLI::log( "Skipped updates for: $exclude" );
		}
	}

	// phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore -- Whitelisting to provide backward compatibility to classes possibly extending this class.
	protected function _list( $_, $assoc_args ) {

		// Force WordPress to check for updates if `--skip-update-check` is not passed.
		if ( false === (bool) Utils\get_flag_value( $assoc_args, 'skip-update-check', false ) ) {
			delete_site_transient( $this->upgrade_transient );
			call_user_func( $this->upgrade_refresh );
		}

		$all_items = $this->get_all_items();

		if ( false !== (bool) Utils\get_flag_value( $assoc_args, 'recently-active', false ) ) {
			$all_items = array_filter(
				$all_items,
				function ( $value ) {
					return isset( $value['recently_active'] ) && true === $value['recently_active'];
				}
			);
		}

		if ( ! is_array( $all_items ) ) {
			WP_CLI::error( "No {$this->item_type}s found." );
		}

		foreach ( $all_items as $key => &$item ) {

			if ( empty( $item['version'] ) ) {
				$item['version'] = '';
			}

			if ( empty( $item['update_version'] ) ) {
				$item['update_version'] = '';
			}

			foreach ( $item as $field => &$value ) {
				if ( 'update' === $field ) {
					// If an update is unavailable, make sure to also show these fields which will explain why
					if ( 'unavailable' === $value ) {
						if ( ! in_array( 'requires', $this->obj_fields, true ) ) {
							array_push( $this->obj_fields, 'requires' );
						}
						if ( ! in_array( 'requires_php', $this->obj_fields, true ) ) {
							array_push( $this->obj_fields, 'requires_php' );
						}
					}
				} elseif ( 'auto_update' === $field ) {
					if ( true === $value ) {
						$value = 'on';
					} elseif ( false === $value ) {
						$value = 'off';
					}
				}
			}

			foreach ( $this->obj_fields as $field ) {
				if ( ! array_key_exists( $field, $assoc_args ) ) {
					continue;
				}

				// This can be either a value to filter by or a comma-separated list of values.
				// Also, it is not forbidden for a value to contain a comma (in which case we can filter only by one).
				$field_filter = $assoc_args[ $field ];
				if (
					$item[ $field ] !== $field_filter
					&& ! in_array( $item[ $field ], array_map( 'trim', explode( ',', $field_filter ) ), true )
				) {
					unset( $all_items[ $key ] );
				}
			}
		}

		$formatter = $this->get_formatter( $assoc_args );
		$formatter->display_items( $all_items );
	}

	/**
	 * Check whether an item has an update available or not.
	 *
	 * @param string $slug The plugin/theme slug
	 *
	 * @return bool
	 */
	protected function has_update( $slug ) {
		$update_list = get_site_transient( $this->upgrade_transient );

		return isset( $update_list->response[ $slug ] );
	}

	/**
	 * Get the available update info
	 *
	 * @return mixed
	 */
	protected function get_update_info() {
		return get_site_transient( $this->upgrade_transient );
	}

	private $map = [
		'short' => [
			'inactive'       => 'I',
			'active'         => 'A',
			'active-network' => 'N',
			'must-use'       => 'M',
			'parent'         => 'P',
			'dropin'         => 'D',
		],
		'long'  => [
			'inactive'       => 'Inactive',
			'active'         => 'Active',
			'active-network' => 'Network Active',
			'must-use'       => 'Must Use',
			'parent'         => 'Parent',
			'dropin'         => 'Drop-In',
		],
	];

	protected function format_status( $status, $format ) {
		return $this->get_color( $status ) . $this->map[ $format ][ $status ];
	}

	private function get_color( $status ) {
		static $colors = [
			'inactive'       => '',
			'active'         => '%g',
			'active-network' => '%g',
			'must-use'       => '%c',
			'parent'         => '%p',
			'dropin'         => '%B',
		];

		return $colors[ $status ];
	}

	/**
	 * Get the minor or patch version for plugins and themes with available updates
	 *
	 * @param array  $items    Items with updates.
	 * @param string $type     Either 'minor' or 'patch'.
	 * @param bool   $insecure Whether to retry without certificate validation on TLS handshake failure.
	 * @param bool   $require_stable Whether to require stable version when comparing versions.
	 * @param string $item_type Item type, either 'plugin' or 'theme'.
	 * @return array
	 */
	private function get_minor_or_patch_updates( $items, $type, $insecure, $require_stable, $item_type ) {
		$wp_org_api = new WpOrgApi( [ 'insecure' => $insecure ] );
		foreach ( $items as $i => $item ) {
			try {
				$data = call_user_func(
					[ $wp_org_api, "get_{$item_type}_info" ],
					$item['name'],
					// The default.
					'en_US',
					// We are only interested in the versions field.
					[ 'versions' => true ]
				);
			} catch ( Exception $exception ) {
				unset( $items[ $i ] );
				continue;
			}
			// No minor or patch versions to access.
			if ( empty( $data['versions'] ) ) {
				unset( $items[ $i ] );
				continue;
			}
			$update_version = false;
			$update_package = false;
			foreach ( $data['versions'] as $version => $download_link ) {
				try {
					$update_type = Utils\get_named_sem_ver( $version, $item['version'] );
				} catch ( \Exception $e ) {
					continue;
				}
				// Compared version must be older.
				if ( ! $update_type ) {
					continue;
				}
				// Only permit 'patch' for 'patch'.
				if ( 'patch' === $type && 'patch' !== $update_type ) {
					continue;
				}
				// Permit 'minor' or 'patch' for 'minor' phpcs:ignore Squiz.PHP.CommentedOutCode.Found -- False positive.
				if ( 'minor' === $type && ! in_array( $update_type, array( 'minor', 'patch' ), true ) ) {
					continue;
				}
				if ( $require_stable && 'stable' !== VersionParser::parseStability( $version ) ) {
					continue;
				}

				if ( $update_version && ! Comparator::greaterThan( $version, $update_version ) ) {
					continue;
				}
				$update_version = $version;
				$update_package = $download_link;
			}
			// If there's not a matching version, bail on updates.
			if ( ! $update_version ) {
				unset( $items[ $i ] );
				continue;
			}
			$items[ $i ]['update_version'] = $update_version;
			$items[ $i ]['update_package'] = $update_package;
		}
		return $items;
	}

	/**
	 * Search wordpress.org repo.
	 *
	 * @param  array $args       A arguments array containing the search term in the first element.
	 * @param  array $assoc_args Data passed in from command.
	 */
	// phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore -- Whitelisting to provide backward compatibility to classes possibly extending this class.
	protected function _search( $args, $assoc_args ) {
		$term = $args[0];

		$defaults   = [
			'per-page' => 10,
			'page'     => 1,
			'fields'   => implode( ',', [ 'name', 'slug', 'rating' ] ),
		];
		$assoc_args = array_merge( $defaults, $assoc_args );
		$fields     = array();
		foreach ( explode( ',', $assoc_args['fields'] ) as $field ) {
			$fields[ $field ] = true;
		}

		$format    = ! empty( $assoc_args['format'] ) ? $assoc_args['format'] : 'table';
		$formatter = $this->get_formatter( $assoc_args );

		$api_args = [
			'per_page' => (int) $assoc_args['per-page'],
			'page'     => (int) $assoc_args['page'],
			'search'   => $term,
			'fields'   => $fields,
		];

		if ( 'plugin' === $this->item_type ) {
			$api = plugins_api( 'query_plugins', $api_args );
		} else {
			$api = themes_api( 'query_themes', $api_args );
		}

		if ( is_wp_error( $api ) ) {
			WP_CLI::error( $api->get_error_message() . __( ' Try again' ) );
		}

		$plural = $this->item_type . 's';

		if ( ! isset( $api->$plural ) ) {
			WP_CLI::error( __( 'API error. Try Again.' ) );
		}

		$items = $api->$plural;

		// Add `url` for plugin or theme on wordpress.org.
		foreach ( $items as $index => $item_object ) {
			if ( $item_object instanceof \stdClass ) {
				$item_object->url = "https://wordpress.org/{$plural}/{$item_object->slug}/";
			}
		}

		if ( 'table' === $format ) {
			$count = Utils\get_flag_value( $api->info, 'results', 'unknown' );
			WP_CLI::success( sprintf( 'Showing %s of %s %s.', count( $items ), $count, $plural ) );
		}

		$formatter->display_items( $items );
	}

	protected function get_formatter( &$assoc_args ) {
		return new \WP_CLI\Formatter( $assoc_args, $this->obj_fields, $this->item_type );
	}

	/**
	 * Error handler to ignore failures on accessing SSL "https://api.wordpress.org/themes/update-check/1.1/" in `wp_update_themes()`
	 * and "https://api.wordpress.org/plugins/update-check/1.1/" in `wp_update_plugins()` which seem to occur intermittently.
	 */
	public static function error_handler( $errno, $errstr, $errfile, $errline, $errcontext = null ) {
		// If ignoring E_USER_WARNING | E_USER_NOTICE, default.
		if ( ! ( error_reporting() & $errno ) ) {
			return false;
		}
		// If not in "wp-includes/update.php", default.
		$update_php = 'wp-includes/update.php';
		if ( 0 !== substr_compare( $errfile, $update_php, -strlen( $update_php ) ) ) {
			return false;
		}
		// Else assume it's in `wp_update_themes()` or `wp_update_plugins()` and just ignore it.
		return true;
	}

	/**
	 * Retrieves PHP_URL_HOST component from URL.
	 *
	 * @param int $component The component to retrieve.
	 *
	 * @return string
	 */
	private function parse_url_host_component( $url, $component ) {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- parse_url will only be used in absence of wp_parse_url.
		return function_exists( 'wp_parse_url' ) ? wp_parse_url( $url, $component ) : parse_url( $url, $component );
	}

	/**
	 * Add versioned GitHub URLs to cache allowlist.
	 *
	 * @param string $url The URL to check.
	 */
	protected static function maybe_cache( $url, $item_type ) {
		$matches = [];

		// cache release URLs like `https://github.com/wp-cli-test/generic-example-plugin/releases/download/v0.1.0/generic-example-plugin.0.1.0.zip`
		if ( preg_match( '#github\.com/[^/]+/([^/]+)/releases/download/v?([^/]+)/.+\.zip#', $url, $matches ) ) {
			WP_CLI::get_http_cache_manager()->whitelist_package( $url, $item_type, $matches[1], $matches[2] );
			// cache archive URLs like `https://github.com/wp-cli-test/generic-example-plugin/archive/v0.1.0.zip`
		} elseif ( preg_match( '#github\.com/[^/]+/([^/]+)/archive/(version/|)v?([^/]+)\.zip#', $url, $matches ) ) {
			WP_CLI::get_http_cache_manager()->whitelist_package( $url, $item_type, $matches[1], $matches[3] );
			// cache release URLs like `https://api.github.com/repos/danielbachhuber/one-time-login/zipball/v0.4.0`
		} elseif ( preg_match( '#api\.github\.com/repos/[^/]+/([^/]+)/zipball/v?([^/]+)#', $url, $matches ) ) {
			WP_CLI::get_http_cache_manager()->whitelist_package( $url, $item_type, $matches[1], $matches[2] );
		}
	}

	/**
	 * Get the latest package version based on a given repo slug.
	 *
	 * @param string $repo_slug
	 *
	 * @return array{ name: string, url: string }|\WP_Error
	 */
	protected function get_the_latest_github_version( $repo_slug ) {
		$api_url = sprintf( $this->github_releases_api_endpoint, $repo_slug );
		$token   = getenv( 'GITHUB_TOKEN' );

		$request_arguments = $token ? [ 'headers' => 'Authorization: Bearer ' . getenv( 'GITHUB_TOKEN' ) ] : [];

		$response = \wp_remote_get( $api_url, $request_arguments );

		if ( \is_wp_error( $response ) ) {
			return $response;
		}

		$body         = \wp_remote_retrieve_body( $response );
		$decoded_body = json_decode( $body );

		// WP_Http::FORBIDDEN doesn't exist in WordPress 3.7
		if ( 403 === wp_remote_retrieve_response_code( $response ) ) {
			return new \WP_Error(
				403,
				$this->build_rate_limiting_error_message( $decoded_body )
			);
		}

		if ( 404 === wp_remote_retrieve_response_code( $response ) ) {
			return new \WP_Error(
				$decoded_body->status,
				$decoded_body->message
			);
		}

		if ( null === $decoded_body ) {
			return new \WP_Error( 500, 'Empty response received from GitHub.com API' );
		}

		if ( ! isset( $decoded_body[0] ) ) {
			return new \WP_Error( '400', 'The given Github repository does not have any releases' );
		}

		$latest_release = $decoded_body[0];

		return [
			'name' => $latest_release->name,
			'url'  => $this->get_asset_url_from_release( $latest_release ),
		];
	}

	/**
	 * Get the asset URL from the release array. When the asset is not present, we fallback to the zipball_url (source code) property.
	 */
	private function get_asset_url_from_release( $release ) {
		if ( isset( $release->assets[0]->browser_download_url ) ) {
			return $release->assets[0]->browser_download_url;
		}

		if ( isset( $release->zipball_url ) ) {
			return $release->zipball_url;
		}

		return null;
	}

	/**
	 * Get the GitHub repo from the URL.
	 *
	 * @param string $url
	 *
	 * @return string|null
	 */
	protected function get_github_repo_from_releases_url( $url ) {
		preg_match( $this->github_latest_release_url, $url, $matches );

		return isset( $matches[1] ) ? $matches[1] : null;
	}

	/**
	 * Build the error message we display in WP-CLI for the API Rate limiting error response.
	 *
	 * @param $decoded_body
	 *
	 * @return string
	 */
	private function build_rate_limiting_error_message( $decoded_body ) {
		return $decoded_body->message . PHP_EOL . $decoded_body->documentation_url . PHP_EOL . 'In order to pass the token to WP-CLI, you need to use the GITHUB_TOKEN environment variable.';
	}
}
