<?php
/**
 * Git Updater
 *
 * @author   Andy Fragen
 * @license  MIT
 * @link     https://github.com/afragen/git-updater
 * @package  git-updater
 */

namespace Fragen\Git_Updater\Traits;

use Fragen\Singleton;
use ReflectionClass;
use ReflectionObject;
use stdClass;
use WP_Error;

use const Fragen\Git_Updater\PLUGIN_FILE;

/**
 * Trait GU_Trait
 */
trait GU_Trait {

	/**
	 * Holds the plugin basename.
	 *
	 * @var string
	 */
	private $gu_plugin_name = 'git-updater/git-updater.php';

	/**
	 * Checks to see if a heartbeat is resulting in activity.
	 *
	 * @return bool
	 */
	final public static function is_heartbeat() {
		if ( isset( $_POST['action'], $_POST['_nonce'] ) && wp_verify_nonce( sanitize_key( wp_unslash( $_POST['_nonce'] ) ), 'heartbeat-nonce' ) ) {
			return 'heartbeat' === $_POST['action'];
		}
			return false;
	}

	/**
	 * Checks to see if WP_CLI.
	 *
	 * @return bool
	 */
	final public static function is_wp_cli() {
		return defined( 'WP_CLI' ) && \WP_CLI;
	}

	/**
	 * Checks to see if DOING_AJAX.
	 *
	 * @return bool
	 */
	final public static function is_doing_ajax() {
		return defined( 'DOING_AJAX' ) && \DOING_AJAX;
	}

	/**
	 * Load site options.
	 */
	final public function load_options() {
		Singleton::get_instance( 'Fragen\Git_Updater\GU_Upgrade', $this )->convert_ghu_options_to_gu_options();
		$base           = Singleton::get_instance( 'Fragen\Git_Updater\Base', $this );
		$base::$options = get_site_option( 'git_updater', [] );
		$base::$options = $this->modify_options( $base::$options );
	}

	/**
	 * Check current page.
	 *
	 * @param  array $pages Array of pages.
	 * @return bool
	 */
	final public function is_current_page( array $pages ) {
		global $pagenow;

		return in_array( $pagenow, $pages, true );
	}

	/**
	 * Returns repo cached data.
	 *
	 * @access protected
	 *
	 * @param string|bool $repo Repo name or false.
	 *
	 * @return array|bool The repo cache. False if expired.
	 */
	final public function get_repo_cache( $repo = false ) {
		if ( ! $repo ) {
			$repo = $this->type->slug ?? 'ghu';
		}
		$cache_key = 'ghu-' . md5( $repo );
		$cache     = get_site_option( $cache_key );

		if ( empty( $cache['timeout'] ) || time() > $cache['timeout'] ) {
			return false;
		}

		return $cache;
	}

	/**
	 * Sets repo data for cache in site option.
	 *
	 * @access protected
	 *
	 * @param string      $id       Data Identifier.
	 * @param mixed       $response Data to be stored.
	 * @param string|bool $repo     Repo name or false.
	 * @param string|bool $timeout  Timeout for cache.
	 *                              Default is $hours (12 hours).
	 *
	 * @return bool
	 */
	final public function set_repo_cache( $id, $response, $repo = false, $timeout = false ) {
		if ( is_wp_error( $response ) ) {
			return false;
		}
		$this->response = property_exists( $this, 'response' ) && is_array( $this->response ) ? $this->response : [];

		$hours = $this->get_class_vars( 'API\API', 'hours' );
		if ( ! $repo ) {
			$repo = $this->type->slug ?? 'ghu';
		}
		$cache_key = 'ghu-' . md5( $repo );
		$timeout   = $timeout ? $timeout : '+' . $hours . ' hours';

		/**
		 * Allow filtering of cache timeout for repo information.
		 *
		 * @since 10.0.0
		 *
		 * @param string      $timeout  Timeout value used with strtotime().
		 * @param string      $id       Data Identifier.
		 * @param mixed       $response Data to be stored.
		 * @param string|bool $repo     Repo name or false.
		 */
		$timeout = apply_filters( 'gu_repo_cache_timeout', $timeout, $id, $response, $repo );

		$this->response['timeout'] = strtotime( $timeout );
		$this->response[ $id ]     = $response;

		update_site_option( $cache_key, $this->response );

		return true;
	}

	/**
	 * Getter for class variables.
	 *
	 * @param string $class_name Name of class.
	 * @param string $name       Name of variable.
	 *
	 * @return mixed
	 */
	final public function get_class_vars( $class_name, $name ) {
		$class          = Singleton::get_instance( $class_name, $this );
		$reflection_obj = new ReflectionObject( $class );
		if ( ! $reflection_obj->hasProperty( $name ) ) {
			return false;
		}
		$property = $reflection_obj->getProperty( $name );
		$property->setAccessible( true );

		return $property->getValue( $class );
	}

	/**
	 * Returns static class variable $error_code.
	 *
	 * @return array self::$error_code
	 */
	final public function get_error_codes() {
		return $this->get_class_vars( 'API\API', 'error_code' );
	}

	/**
	 * Function to check if plugin or theme object is able to be updated.
	 *
	 * @param stdClass $type Repo object.
	 *
	 * @return bool
	 */
	final public function can_update_repo( $type ) {
		$wp_version_ok   = ! empty( $type->requires )
			? is_wp_version_compatible( $type->requires )
			: true;
		$php_version_ok  = ! empty( $type->requires_php )
			? is_php_version_compatible( $type->requires_php )
			: true;
		$remote_is_newer = isset( $type->remote_version, $type->local_version )
			? version_compare( $type->remote_version, $type->local_version, '>' )
			: false;

		/**
		 * Filter $remote_is_newer if you use another method to test for updates.
		 *
		 * @since 10.0.0
		 * @param bool     $remote_is_newer
		 * @param stdClass $type            Plugin/Theme data.
		 */
		$remote_is_newer = apply_filters( 'gu_remote_is_newer', $remote_is_newer, $type );

		return $remote_is_newer && $wp_version_ok && $php_version_ok;
	}

	/**
	 * Delete all `ghu-` prefixed data from options table.
	 *
	 * @return bool
	 */
	final public function delete_all_cached_data() {
		global $wpdb;

		$table              = is_multisite() ? $wpdb->base_prefix . 'sitemeta' : $wpdb->base_prefix . 'options';
		$column             = is_multisite() ? 'meta_key' : 'option_name';
		$delete_string      = 'DELETE FROM ' . $table . ' WHERE ' . $column . ' LIKE %s LIMIT 1000';
		$get_options_string = 'SELECT * FROM ' . $table . ' WHERE ' . $column . ' LIKE %s';

		$ghu_options = $wpdb->get_results( $wpdb->prepare( $get_options_string, [ '%ghu-%' ] ) ); // phpcs:ignore
		foreach ( $ghu_options as $option ) {
			delete_site_option( $option->option_name );
		}

		$wpdb->query( $wpdb->prepare( $delete_string, [ '%ghu-%' ] ) ); // phpcs:ignore

		wp_cron();

		return true;
	}

	/**
	 * Is this a private repo with a token/checked or needing token/checked?
	 * Test for whether remote_version is set ( default = 0.0.0 ) or
	 * a repo option is set/not empty.
	 *
	 * @param stdClass $repo Repository.
	 *
	 * @return bool
	 */
	final public function is_private( $repo ) {
		if ( ! isset( $repo->remote_version ) && ! self::is_doing_ajax() ) {
			return true;
		}
		if ( isset( $repo->remote_version ) && ! self::is_doing_ajax() ) {
			return ( '0.0.0' === $repo->remote_version ) || ! empty( self::$options[ $repo->slug ] );
		}

		return false;
	}

	/**
	 * Do we override dot org updates?
	 *
	 * @param string   $type (plugin|theme).
	 * @param stdClass $repo Repository object.
	 *
	 * @return bool
	 */
	final public function override_dot_org( $type, $repo ) {
		// Correctly account for dashicon in Settings page.
		$icon           = is_array( $repo );
		$repo           = is_array( $repo ) ? (object) $repo : $repo;
		$dot_org_master = ! $icon ? property_exists( $repo, 'dot_org' ) && $repo->dot_org && $repo->primary_branch === $repo->branch : true;

		$transient_key = 'plugin' === $type ? $repo->file : null;
		$transient_key = 'theme' === $type ? $repo->slug : $transient_key;

		$overrides = apply_filters( 'gu_override_dot_org', [] );
		$override  = in_array( $transient_key, $overrides, true );

		// Set $override if set in Skip Updates plugin.
		if ( ! $override && class_exists( '\\Fragen\\Skip_Updates\\Bootstrap' ) ) {
			$skip_updates = get_site_option( 'skip_updates', [] );
			foreach ( $skip_updates as $skip ) {
				if ( $repo->file === $skip['slug'] ) {
					$override = true;
					break;
				}
			}
		}

		/**
		 * Filter hook to completely ignore any updates from dot org when using Git Updater.
		 *
		 * @since 12.6.0
		 * @param bool Default is false. Do not ignore updates from dot org.
		 */
		return ! $dot_org_master || $override || apply_filters( 'gu_ignore_dot_org', false );
	}

	/**
	 * Sanitize each setting field as needed.
	 *
	 * @param array $input Contains all settings fields as array keys.
	 *
	 * @return array
	 */
	final public function sanitize( $input ) {
		$new_input = [];
		foreach ( array_keys( (array) $input ) as $id ) {
			$new_input[ sanitize_title_with_dashes( $id ) ] = sanitize_text_field( $input[ $id ] );
		}

		return $new_input;
	}

	/**
	 * Return an array of the running git servers.
	 *
	 * @access public
	 * @return array $gits
	 */
	final public function get_running_git_servers() {
		$plugins = Singleton::get_instance( 'Fragen\Git_Updater\Plugin', $this )->get_plugin_configs();
		$themes  = Singleton::get_instance( 'Fragen\Git_Updater\Theme', $this )->get_theme_configs();

		$repos = array_merge( $plugins, $themes );
		$gits  = array_map(
			function ( $e ) {
				return $e->git;
			},
			$repos
		);

		/**
		 * Filter array of repository git servers.
		 *
		 * @since 10.0.0
		 * @param array $gits  Array of repository git servers.
		 * @param array $repos Array of repository objects.
		 */
		$gits = apply_filters( 'gu_running_git_servers', $gits, $repos );

		return array_unique( array_values( $gits ) );
	}

	/**
	 * Check to see if wp-cron/background updating has finished.
	 * Or not managed by Git Updater.
	 *
	 * @param null|stdClass $repo Repo object.
	 *
	 * @return bool true when waiting for background job to finish.
	 */
	final protected function waiting_for_background_update( $repo = null ) {
		$caches = [];
		if ( null !== $repo ) {

			// Getting class instance also runs API::settings_hook().
			if ( isset( $repo->git ) ) {
				$git_class = 'Fragen\\Git_Updater\\API\\' . $this->base::$git_servers[ $repo->git ] . '_API';
				Singleton::get_instance( $git_class, $this );
			}

			$cache = isset( $repo->slug ) ? $this->get_repo_cache( $repo->slug ) : null;

			// Probably not managed by Git Updater if $cache is empty.
			return empty( $cache );
		}

		$repos = array_merge(
			Singleton::get_instance( 'Fragen\Git_Updater\Plugin', $this )->get_plugin_configs(),
			Singleton::get_instance( 'Fragen\Git_Updater\Theme', $this )->get_theme_configs()
		);

		/**
		 * Filter to modify array of repos.
		 *
		 * @since 10.2.0
		 * @param array $repos Array of repositories.
		 */
		$repos = apply_filters( 'gu_config_pre_process', $repos );

		foreach ( $repos as $git_repo ) {
			$caches[ $git_repo->slug ] = $this->get_repo_cache( $git_repo->slug );
		}
		$waiting = array_filter(
			$caches,
			function ( $e ) {
				return empty( $e );
			}
		);

		return ! empty( $waiting );
	}

	/**
	 * Parse URI param returning array of parts.
	 *
	 * @param string $repo_header Repo URL.
	 *
	 * @return array $header
	 */
	final protected function parse_header_uri( $repo_header ) {
		$header_parts         = parse_url( $repo_header );
		$header_path          = pathinfo( $header_parts['path'] );
		$header['original']   = $repo_header;
		$header['scheme']     = $header_parts['scheme'] ?? null;
		$header['host']       = $header_parts['host'] ?? null;
		$header['owner']      = trim( $header_path['dirname'], '/' );
		$header['repo']       = isset( $header_path['extension'] ) && 'git' === $header_path['extension'] ? $header_path['filename'] : $header_path['basename'];
		$header['owner_repo'] = implode( '/', [ $header['owner'], $header['repo'] ] );
		$header['base_uri']   = str_replace( $header_parts['path'], '', $repo_header );
		$header['uri']        = isset( $header['scheme'] ) ? trim( $repo_header, '/' ) : null;

		$header = $this->sanitize( $header );

		return $header;
	}

	/**
	 * Create repo parts.
	 *
	 * @param string $repo Repo type.
	 * @param string $type plugin|theme.
	 *
	 * @return mixed
	 */
	final protected function get_repo_parts( $repo, $type ) {
		$extra_repo_headers = $this->get_class_vars( 'Base', 'extra_repo_headers' );

		$arr['bool'] = false;
		$pattern     = '/' . strtolower( $repo ) . '_/';
		$type        = preg_replace( $pattern, '', $type );

		$repos = [
			'types' => [ 'GitHub' => 'github_' . $type ],
			'uris'  => [ 'GitHub' => 'https://github.com/' ],
		];

		/**
		 * Filter repo parts from other git hosts.
		 *
		 * @since 10.0.0
		 * @param array $repos Array of repo data.
		 */
		$repos = apply_filters( 'gu_get_repo_parts', $repos, $type );

		if ( array_key_exists( $repo, $repos['types'] ) ) {
			$arr['type']       = $repos['types'][ $repo ];
			$arr['git_server'] = strtolower( $repo );
			$arr['base_uri']   = $repos['uris'][ $repo ];
			$arr['bool']       = true;
			foreach ( $extra_repo_headers as $key => $value ) {
				$arr[ $key ] = $repo . ' ' . $value;
			}
		}

		return $arr;
	}

	/**
	 * Set array with normal repo names.
	 * Fix name even if installed without renaming originally, eg <repo>-master
	 *
	 * @param string            $slug            Repo slug.
	 * @param Base|Plugin|Theme $upgrader_object Upgrader object.
	 *
	 * @return array
	 */
	final protected function get_repo_slugs( $slug, $upgrader_object = null ) {
		$arr    = [];
		$slug   = (string) $slug;
		$rename = explode( '-', $slug );
		array_pop( $rename );
		$rename = implode( '-', $rename );

		// For AJAX install, not from Install tab, slug is correct. Refer to Add-Ons.
		if ( ( ! isset( $_POST['git_updater_repo'] ) && isset( $_POST['action'] ) )
			&& ( wp_doing_ajax() && check_ajax_referer( 'updates' ) )
		) {
			if ( str_contains( sanitize_key( wp_unslash( $_POST['action'] ) ), 'install' ) ) {
				$arr['slug'] = $slug;
			}
		}

		if ( null === $upgrader_object ) {
			$upgrader_object = $this;
		}

		$rename = isset( $upgrader_object->config[ $slug ] ) ? $slug : $rename;
		$config = $this->get_class_vars( ( new ReflectionClass( $upgrader_object ) )->getShortName(), 'config' );

		foreach ( (array) $config as $repo ) {
			// Check repo slug or directory name for match.
			$slug_check = [
				$repo->slug,
				dirname( $repo->file ),
			];

			// Exact match.
			if ( in_array( $slug, $slug_check, true ) ) {
				$arr['slug'] = $repo->slug;
				break;
			}

			// Soft match, there may still be an exact $slug match.
			if ( in_array( $rename, $slug_check, true ) ) {
				// $arr['slug'] = $repo->slug;
			}
		}

		return $arr;
	}

	/**
	 * Get default headers plus extra headers.
	 *
	 * @param string $type plugin|theme.
	 *
	 * @return array
	 */
	final public function get_headers( $type ) {
		$default_plugin_headers = [
			'Name'            => 'Plugin Name',
			'PluginURI'       => 'Plugin URI',
			'Version'         => 'Version',
			'Description'     => 'Description',
			'Author'          => 'Author',
			'AuthorURI'       => 'Author URI',
			'License'         => 'License',
			'TextDomain'      => 'Text Domain',
			'DomainPath'      => 'Domain Path',
			'Network'         => 'Network',
			'Requires'        => 'Requires at least',
			'RequiresPHP'     => 'Requires PHP',
			'UpdateURI'       => 'Update URI',
			'RequiresPlugins' => 'Requires Plugins',
		];

		$default_theme_headers = [
			'Name'        => 'Theme Name',
			'ThemeURI'    => 'Theme URI',
			'Description' => 'Description',
			'Author'      => 'Author',
			'AuthorURI'   => 'Author URI',
			'Version'     => 'Version',
			'License'     => 'License',
			'Template'    => 'Template',
			'Status'      => 'Status',
			'Tags'        => 'Tags',
			'TextDomain'  => 'Text Domain',
			'DomainPath'  => 'Domain Path',
			'Requires'    => 'Requires at least',
			'RequiresPHP' => 'Requires PHP',
			'UpdateURI'   => 'Update URI',
		];

		$all_headers = array_merge( ${"default_{$type}_headers"}, self::$extra_headers );

		return $all_headers;
	}

	/**
	 * Take remote file contents as string or array and parse and reduce headers.
	 *
	 * @param string|array $contents File contents or array of file headers.
	 * @param string       $type     plugin|theme.
	 *
	 * @return array $all_headers Reduced array of all headers.
	 */
	final public function get_file_headers( $contents, $type ) {
		$all_headers = [];
		$all_headers = $this->get_headers( $type );
		$all_headers = array_unique( $all_headers );

		/*
		 * Make sure we catch CR-only line endings.
		 */
		if ( is_string( $contents ) ) {
			$file_data = str_replace( "\r", "\n", $contents );

			foreach ( $all_headers as $field => $regex ) {
				if ( preg_match( '/^[ \t\/*#@]*' . preg_quote( $regex, '/' ) . ':(.*)$/mi', $file_data, $match ) && $match[1] ) {
					$all_headers[ $field ] = _cleanup_header_comment( $match[1] );
				} else {
					$all_headers[ $field ] = '';
				}
			}
		}

		$all_headers = is_array( $contents ) ? $contents : $all_headers;

		// Reduce array to only headers with data.
		$all_headers = array_filter( $all_headers );

		return $all_headers;
	}

	/**
	 * Parse Enterprise, Languages, Release Asset, and CI Job headers for plugins and themes.
	 *
	 * @param array $header       Array of repo data.
	 * @param array $headers      Array of repo header data.
	 * @param array $header_parts Array of header parts.
	 *
	 * @return array
	 */
	final public function parse_extra_headers( $header, $headers, $header_parts ) {
		$extra_repo_headers = $this->get_class_vars( 'Base', 'extra_repo_headers' );

		$header['enterprise_uri'] = null;
		$header['enterprise_api'] = null;
		$header['languages']      = null;
		$header['ci_job']         = false;
		$header['release_asset']  = false;
		$header['primary_branch'] = false;
		$header['did']            = null;

		if ( ! empty( $header['host'] ) ) {
			if ( 'GitHub' === $header_parts[0] && ! str_contains( $header['host'], 'github.com' ) ) {
				$header['enterprise_uri']  = $header['base_uri'];
				$header['enterprise_api']  = trim( $header['enterprise_uri'], '/' );
				$header['enterprise_api'] .= '/api/v3';
			}

			/**
			 * Filter REST endpoint for API.
			 *
			 * @since 10.0.0
			 * @param array  $header          Array or repo header data.
			 * @param string $header_parts[0] Name of git host.
			 */
			$header = apply_filters( 'gu_parse_enterprise_headers', $header, $header_parts[0] );
		}

		$self_hosted_parts = array_keys( $extra_repo_headers );
		foreach ( $self_hosted_parts as $part ) {
			if ( ! empty( $headers[ $header_parts[0] . $part ] ) ) {
				switch ( $part ) {
					case 'Languages':
						$header['languages'] = $headers[ $header_parts[0] . $part ];
						break;
					case 'CIJob':
						$header['ci_job'] = $headers[ $header_parts[0] . $part ];
						break;
				}
			}
		}
		$header['release_asset']  = ! $header['release_asset'] && ! empty( $headers['ReleaseAsset'] ) ? true === (bool) $headers['ReleaseAsset'] : $header['release_asset'];
		$header['primary_branch'] = ! $header['primary_branch'] && ! empty( $headers['PrimaryBranch'] ) ? $headers['PrimaryBranch'] : 'master';

		$header['did'] = ! empty( $headers['PluginID'] ) ? $headers['PluginID'] : '';
		$header['did'] = ! empty( $headers['ThemeID'] ) ? $headers['ThemeID'] : $header['did'];

		return $header;
	}

	/**
	 * Check to see if there's already a cron event for $hook.
	 *
	 * @param string $hook Cron event hook.
	 *
	 * @return bool
	 */
	final public function is_cron_event_scheduled( $hook ) {
		foreach ( wp_get_ready_cron_jobs() as $timestamp => $event ) {
			if ( key( $event ) === $hook ) {
				$this->is_cron_overdue( $timestamp );
				return true;
			}
		}

		return false;
	}

	/**
	 * Check to see if wp-cron event is overdue by 24 hours and report error message.
	 *
	 * @param int $timestamp WP-Cron event timestamp.
	 *
	 * @return void
	 */
	final public function is_cron_overdue( $timestamp ) {
		$overdue = ( ( time() - $timestamp ) / HOUR_IN_SECONDS ) > 24;
		if ( $overdue ) {
			$error_msg = esc_html__( 'There may be a problem with WP-Cron. A Git Updater WP-Cron event is overdue.', 'git-updater' );
			$error     = new WP_Error( 'git_updater_cron_error', $error_msg );
			Singleton::get_instance( 'Fragen\Git_Updater\Messages', $this )->create_error_message( $error );
		}
	}

	/**
	 * Returns current plugin version.
	 *
	 * @return string Git Updater plugin version
	 */
	final public static function get_plugin_version() {
		$plugin_version = get_file_data( dirname( __DIR__, 3 ) . '/git-updater.php', [ 'Version' => 'Version' ] )['Version'];

		return $plugin_version;
	}

	/**
	 * Test whether to use release asset.
	 *
	 * @param bool|string $branch_switch Branch to switch to or false.
	 *
	 * @return bool
	 */
	final public function use_release_asset( $branch_switch = false ) {
		$is_tag                  = property_exists( $this->type, 'branches' ) ? $branch_switch && ! array_key_exists( $branch_switch, (array) $this->type->branches ) : false;
		$switch_master_tag       = $this->type->primary_branch === $branch_switch || $is_tag;
		$current_master_noswitch = $this->type->primary_branch === $this->type->branch && false === $branch_switch;

		$need_release_asset = $switch_master_tag || $current_master_noswitch;
		$use_release_asset  = $this->type->release_asset && '0.0.0' !== $this->type->newest_tag
			&& $need_release_asset;

		return $use_release_asset;
	}

	/**
	 * Modify options without saving.
	 *
	 * Check if a filter effecting a checkbox is set elsewhere.
	 * Adds value '-1' without saving so that checkbox is checked and disabled.
	 *
	 * @param  array $options Site options.
	 * @return array
	 */
	private function modify_options( $options ) {
		// Remove any inadvertently saved options with value -1.
		$options = array_filter(
			$options,
			function ( $e ) {
				return '-1' !== $e;
			}
		);

		if ( ! isset( $options['branch_switch'] ) ) {
			$options['branch_switch'] = '0';
		}

		if ( ! isset( $options['bypass_background_processing'] ) ) {
			$options['bypass_background_processing'] = '0';
		}

		// Check if filter set elsewhere.
		$disable_wp_cron = (bool) apply_filters( 'gu_disable_wpcron', false );
		if ( $disable_wp_cron ) {
			$options['bypass_background_processing'] = '1';
		}

		return $options;
	}

	/**
	 * Get WP and PHP requirements from main plugin/theme file.
	 *
	 * @param stdClass $repo Repository object.
	 *
	 * @return array
	 */
	final protected function get_repo_requirements( $repo ) {
		$requires      = [
			'RequiresPHP' => 'Requires PHP',
			'RequiresWP'  => 'Requires at least',
		];
		$default_empty = [
			'RequiresPHP' => null,
			'RequiresWP'  => null,
		];
		$filepath      = 'gist' === $repo->git
			? trailingslashit( dirname( $repo->local_path ) ) . $repo->file
			: '';
		$repo_data     = file_exists( $filepath ) ? get_file_data( $filepath, $requires ) : $default_empty;

		return $repo_data;
	}

	/**
	 * Deletes temporary upgrade directory.
	 *
	 * @since 10.10.0
	 * @uses `upgrader_install_package_result` filter
	 *
	 * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass.
	 *
	 * @param array|WP_Error $result Result from WP_Upgrader::install_package().

	 * @return bool
	 */
	final public function delete_upgrade_source( $result ) {
		global $wp_filesystem;

		if ( ! is_wp_error( $result ) && ! empty( $result['destination_name'] ) ) {
			$wp_filesystem->delete(
				$wp_filesystem->wp_content_dir() . "upgrade/{$result['destination_name']}",
				true
			);
		}

		return $result;
	}

	/**
	 * Get hash of DID.
	 *
	 * @param  string $did DID.
	 *
	 * @return string
	 */
	final public function get_did_hash( $did ): string {
		return substr( hash( 'sha256', $did ), 0, 6 );
	}

	/**
	 * Return plugin file without DID hash.
	 *
	 * Assumes pattern of <slug>-<hash>.
	 *
	 * @param string $did DID.
	 * @param string $plugin Plugin basename.
	 *
	 * @return string
	 */
	final public function get_file_without_did_hash( $did, $plugin ): string {
		list( $slug, $file ) = explode( '/', $plugin, 2 );
		$slug                = str_replace( '-' . $this->get_did_hash( $did ), '', $slug );

		return $slug . '/' . $file;
	}
}
