Source: includes/utils.php

<?php
/**
 * ElasticPress utility functions
 *
 * @since  3.0
 * @package elasticpress
 */

namespace ElasticPress\Utils;

use ElasticPress\IndexHelper;

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

/**
 * Retrieve the EPIO subscription credentials.
 *
 * @since 2.5
 * @return array
 */
function get_epio_credentials() {
	if ( defined( 'EP_CREDENTIALS' ) && EP_CREDENTIALS ) {
		$raw_credentials = explode( ':', EP_CREDENTIALS );
		if ( is_array( $raw_credentials ) && 2 === count( $raw_credentials ) ) {
			$credentials = array(
				'username' => $raw_credentials[0],
				'token'    => $raw_credentials[1],
			);
		}
		$credentials = sanitize_credentials( $credentials );
	} elseif ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK && is_epio() ) {
		$credentials = sanitize_credentials( get_site_option( 'ep_credentials', false ) );
	} elseif ( is_epio() ) {
		$credentials = sanitize_credentials( get_option( 'ep_credentials', false ) );
	} else {
		$credentials = [
			'username' => '',
			'token'    => '',
		];
	}

	if ( ! is_array( $credentials ) ) {
		return [
			'username' => '',
			'token'    => '',
		];
	}

	return $credentials;
}

/**
 * Get WP capability needed for a user to interact with ElasticPress in the admin
 *
 * @since 4.5.0, 5.1.0 added $context
 * @param string $context Context for the capability. Defaults to empty string.
 * @return string
 */
function get_capability( string $context = '' ) : string {
	/**
	 * Filter the WP capability needed to interact with ElasticPress in the admin
	 *
	 * Example:
	 * ```
	 * add_filter(
	 *     'ep_capability',
	 *     function ( $cacapability, $context ) {
	 *         return ( 'synonyms' === $context ) ?
	 *            'manage_elasticpress_synonyms' :
	 *            $cacapability;
	 *     },
	 *     10,
	 *     2
	 * );
	 * ```
	 *
	 * @since 4.5.0, 5.1.0 added $context
	 * @hook ep_capability
	 * @param  {string} $capability Capability name. Defaults to `'manage_elasticpress'`
	 * @param  {string} $context    Additional context
	 * @return {string} New capability value
	 */
	return apply_filters( 'ep_capability', 'manage_elasticpress', $context );
}

/**
 * Get WP capability needed for a user to interact with ElasticPress in the network admin
 *
 * @since 4.5.0, 5.1.0 added $context
 * @param string $context Context for the capability. Defaults to empty string.
 * @return string
 */
function get_network_capability( string $context = '' ) : string {
	/**
	 * Filter the WP capability needed to interact with ElasticPress in the network admin
	 *
	 * @since 4.5.0, 5.1.0 added $context
	 * @hook ep_network_capability
	 * @param  {string} $capability Capability name. Defaults to `'manage_network_elasticpress'`
	 * @param  {string} $context    Additional context
	 * @return {string} New capability value
	 */
	return apply_filters( 'ep_network_capability', 'manage_network_elasticpress', $context );
}

/**
 * Get mapped capabilities for post types
 *
 * @since 4.5.0, 5.1.0 added $context
 * @param string $context Context for the capability. Defaults to empty string.
 * @return array
 */
function get_post_map_capabilities( string $context = '' ) : array {
	$capability = get_capability( $context );

	return [
		'edit_post'          => $capability,
		'edit_posts'         => $capability,
		'edit_others_posts'  => $capability,
		'publish_posts'      => $capability,
		'read_post'          => $capability,
		'read_private_posts' => $capability,
		'delete_post'        => $capability,
	];
}

/**
 * Get shield credentials
 *
 * @since  3.0
 * @return string|bool
 */
function get_shield_credentials() {
	if ( defined( 'ES_SHIELD' ) && ES_SHIELD ) {
		return ES_SHIELD;
	} elseif ( is_epio() ) {
		$credentials = get_epio_credentials();

		return $credentials['username'] . ':' . $credentials['token'];
	}

	return false;
}

/**
 * Retrieve the appropriate index prefix. Will default to EP_INDEX_PREFIX constant if it exists
 * AKA Subscription ID.
 *
 * @since 2.5
 * @return string|bool
 */
function get_index_prefix() {
	if ( defined( 'EP_INDEX_PREFIX' ) && \EP_INDEX_PREFIX ) {
		$prefix = \EP_INDEX_PREFIX;
	} elseif ( is_epio() ) {
		$credentials = get_epio_credentials();
		$prefix      = $credentials['username'];
		if (
			( ! defined( 'EP_IS_NETWORK' ) || ! EP_IS_NETWORK ) &&
			( '-' !== substr( $prefix, - 1 ) )
		) {
			$prefix .= '-';
		}
	} else {
		$prefix = '';
	}

	/**
	 * Filter index prefix. Defaults to nothing
	 *
	 * @since  2.5
	 * @hook ep_index_prefix
	 * @param  {string} $prefix Current prefix
	 * @return  {string} New prefix
	 */
	return apply_filters( 'ep_index_prefix', $prefix );
}

/**
 * Check if the host is ElasticPress.io.
 *
 * @since  2.6
 * @return bool
 */
function is_epio() {
	return filter_var( preg_match( '#elasticpress\.io#i', get_host() ), FILTER_VALIDATE_BOOLEAN );
}

/**
 * Determine if we should index a blog/site
 *
 * @param  int $blog_id Blog/site id.
 * @since  3.2
 * @return boolean
 */
function is_site_indexable( $blog_id = null ) {
	if ( ! is_multisite() ) {
		return true;
	}

	$site = get_site( $blog_id );

	$is_indexable = get_site_meta( $site['blog_id'], 'ep_indexable', true );

	return 'no' !== $is_indexable && ! $site['deleted'] && ! $site['archived'] && ! $site['spam'];
}

/**
 * Sanitize EPIO credentials prior to storing them.
 *
 * @param array $credentials Array containing username and token.
 * @since  2.6
 * @return array
 */
function sanitize_credentials( $credentials ) {
	if ( ! is_array( $credentials ) ) {
		return [
			'username' => '',
			'token'    => '',
		];
	}

	return [
		'username' => ( isset( $credentials['username'] ) ) ? sanitize_text_field( $credentials['username'] ) : '',
		'token'    => ( isset( $credentials['token'] ) ) ? sanitize_text_field( $credentials['token'] ) : '',
	];
}

/**
 * Determine if ElasticPress is in the middle of an index
 *
 * @since  3.0
 * @return boolean
 */
function is_indexing() {
	/**
	 * Filter whether an index is occurring in dashboard or CLI
	 *
	 * @since  3.0
	 * @hook ep_is_indexing
	 * @param  {bool} $indexing True for indexing
	 * @return {bool} New indexing value
	 */
	return apply_filters( 'ep_is_indexing', ! empty( IndexHelper::factory()->get_index_meta() ) );
}

/**
 * Check if wpcli indexing is occurring
 *
 * @since  3.0
 * @return boolean
 */
function is_indexing_wpcli() {
	$index_meta = IndexHelper::factory()->get_index_meta();

	/**
	 * Filter whether a CLI sync is occurring
	 *
	 * @since  3.0
	 * @hook ep_is_indexing_wpcli
	 * @param  {bool} $indexing True for indexing
	 * @return {bool} New indexing value
	 */
	return apply_filters( 'ep_is_indexing_wpcli', ( ! empty( $index_meta ) && 'cli' === $index_meta['method'] ) );
}

/**
 * Retrieve the appropriate host. Will default to EP_HOST constant if it exists
 *
 * @since 2.1
 * @return string|bool
 */
function get_host() {

	if ( defined( 'EP_HOST' ) && EP_HOST ) {
		$host = EP_HOST;
	} else {
		$host = get_option( 'ep_host', false );
	}

	/**
	 * Filter ElasticPress host to use
	 *
	 * @since  2.1
	 * @hook ep_host
	 * @param  {string} $host Current EP host
	 * @return  {string} Host to use
	 */
	return apply_filters( 'ep_host', $host );
}

/**
 * Get a site. Wraps get_site for formatting purposes
 *
 * @param  int $site_id Site/blog id
 * @since 3.2
 * @return array
 */
function get_site( $site_id ) {
	$site = \get_site( $site_id );

	return [
		'blog_id'  => $site->blog_id,
		'domain'   => $site->domain,
		'path'     => $site->path,
		'site_id'  => $site->site_id,
		'deleted'  => $site->deleted,
		'archived' => $site->archived,
		'spam'     => $site->spam,
	];
}

/**
 * Wrapper function for get_sites - allows us to have one central place for the `ep_indexable_sites` filter
 *
 * @param int  $limit          The maximum amount of sites retrieved, Use 0 to return all sites.
 * @param bool $only_indexable Whether should be returned only indexable sites or not.
 * @since 3.0, 4.7.0 added `$only_indexable`
 * @return array
 */
function get_sites( $limit = 0, $only_indexable = false ) {
	if ( ! is_multisite() ) {
		return [];
	}

	$args = [
		'limit'  => $limit,
		'number' => $limit,
	];

	if ( $only_indexable ) {
		$args = array_merge(
			$args,
			[
				'spam'       => 0,
				'deleted'    => 0,
				'archived'   => 0,
				'meta_query' => [
					'relation' => 'OR',
					[
						'key'     => 'ep_indexable',
						'value'   => 'no',
						'compare' => '!=',
					],
					[
						'key'     => 'ep_indexable',
						'compare' => 'NOT EXISTS',
					],
				],
			]
		);
	}

	/**
	 * Filter arguments to use to query for sites on network
	 *
	 * @since  2.1
	 * @hook ep_indexable_sites_args
	 * @param  {array} $args Array of args to query sites with. See WP_Site_Query
	 * @return {array} New arguments
	 */
	$args = apply_filters( 'ep_indexable_sites_args', $args );

	$site_objects = \get_sites( $args );
	$sites        = [];

	foreach ( $site_objects as $site ) {
		$sites[] = get_site( $site->blog_id );
	}

	/**
	 * Filter indexable sites
	 *
	 * @since  3.0
	 * @hook ep_indexable_sites
	 * @param  {array} $sites Current sites. Instances of WP_Site
	 * @return  {array} New array of sites
	 */
	return apply_filters( 'ep_indexable_sites', $sites );
}

/**
 * Whether plugin is network activated
 *
 * Determines whether plugin is network activated or just on the local site.
 *
 * @since 3.0
 * @param string $plugin the plugin base name.
 * @return bool True if network activated or false.
 */
function is_network_activated( $plugin ) {

	$plugins = get_site_option( 'active_sitewide_plugins' );

	if ( is_multisite() && isset( $plugins[ $plugin ] ) ) {
		return true;
	}

	return false;
}


/**
 * Performant utility function for building a term tree.
 *
 * Tree will look like this:
 * [
 *      WP_Term(
 *          name
 *          slug
 *          children ->[
 *              WP_Term()
 *          ]
 *      ),
 *      WP_Term()
 * ]
 *
 * @param  array       $all_terms Pass get_terms() as this argument where terms are objects NOT arrays.
 * @param  string|bool $orderby   Can be count|name|false. This is how each tree branch will be ordered.
 * @param  string      $order     Can be asc|desc. This is the direction ordering will occur.
 * @param  bool        $flat      If false, a tree will be returned e.g. an array of top level terms
 *                                which children linked within each node. If true, the tree will be
 *                                "flattened".
 * @since  2.5
 * @return array
 */
function get_term_tree( $all_terms, $orderby = 'count', $order = 'desc', $flat = false ) {
	$terms_map    = [];
	$terms_tree   = [];
	$iteration_id = 0;

	while ( true ) {
		if ( empty( $all_terms ) ) {
			break;
		}

		foreach ( $all_terms as $key => $term ) {
			$iteration_id++;

			if ( ! isset( $term->children ) ) {
				$term->children = [];
			}

			if ( ! isset( $terms_map[ $term->term_id ] ) ) {
				$terms_map[ $term->term_id ] = $term;
			}

			$parent_term = get_term( $term->parent, $term->taxonomy );

			if ( empty( $term->parent ) || is_wp_error( $parent_term ) || ! $parent_term ) {
				$term->level = 0;

				if ( empty( $orderby ) ) {
					$terms_tree[] = $term;
				} elseif ( 'count' === $orderby ) {
					/**
					 * We add this weird number to get past terms with the same count
					 */
					$terms_tree[ ( ( $term->count * 10000000 ) + $iteration_id ) ] = $term;
				} elseif ( 'name' === $orderby ) {
					$terms_tree[ strtolower( $term->name ) ] = $term;
				}

				unset( $all_terms[ $key ] );
			} else {
				if ( ! empty( $terms_map[ $term->parent ] ) && isset( $terms_map[ $term->parent ]->level ) ) {

					if ( empty( $orderby ) ) {
						$terms_map[ $term->parent ]->children[] = $term;
					} elseif ( 'count' === $orderby ) {
						$terms_map[ $term->parent ]->children[ ( ( $term->count * 10000000 ) + $iteration_id ) ] = $term;
					} elseif ( 'name' === $orderby ) {
						$terms_map[ $term->parent ]->children[ $term->name ] = $term;
					}

					$parent_level = ( $terms_map[ $term->parent ]->level ) ? $terms_map[ $term->parent ]->level : 0;

					$term->level       = $parent_level + 1;
					$term->parent_term = $terms_map[ $term->parent ];

					unset( $all_terms[ $key ] );
				}
			}
		}
	}

	if ( ! empty( $orderby ) ) {
		if ( 'asc' === $order ) {
			ksort( $terms_tree );
		} else {
			krsort( $terms_tree );
		}

		foreach ( $terms_map as $term ) {
			if ( 'asc' === $order ) {
				ksort( $term->children );
			} else {
				krsort( $term->children );
			}

			$term->children = array_values( $term->children );
		}

		$terms_tree = array_values( $terms_tree );
	}

	if ( $flat ) {
		$flat_tree = [];

		foreach ( $terms_tree as $term ) {
			$flat_tree[] = $term;
			$to_process  = $term->children;
			while ( ! empty( $to_process ) ) {
				$term        = array_shift( $to_process );
				$flat_tree[] = $term;

				if ( ! empty( $term->children ) ) {
					$to_process = array_merge( $term->children, $to_process );
				}
			}
		}

		return $flat_tree;
	}

	return $terms_tree;
}

/**
 * Returns the default language for ES mapping.
 *
 * @return string Default EP language.
 */
function get_language() {
	$ep_language = get_option( 'ep_language' );
	$ep_language = ! empty( $ep_language ) ? $ep_language : 'site-default';

	/**
	 * Filter the default language to use at index time
	 *
	 * @since  3.1
	 * @param {string} The current language.
	 * @hook ep_default_language
	 * @return  {string} New language
	 */
	return apply_filters( 'ep_default_language', $ep_language );
}

/**
 * Returns the status of an ongoing index operation.
 *
 * Returns the status of an ongoing index operation in array with the following fields:
 * indexing | boolean | True if index operation is ongoing or false
 * method | string | 'cli', 'web' or 'none'
 * items_indexed | integer | Total number of items indexed
 * total_items | integer | Total number of items indexed or -1 if not yet determined
 * slug | string | The slug of the indexable
 *
 * @since  3.5.2
 * @return array|boolean
 */
function get_indexing_status() {

	$index_status = false;

	$index_meta = IndexHelper::factory()->get_index_meta();

	if ( ! empty( $index_meta ) ) {
		$index_status = $index_meta;

		$index_status['indexing'] = true;

		if ( ! empty( $index_meta['current_sync_item'] ) ) {
			$index_status['items_indexed'] = $index_meta['current_sync_item']['synced'];
			$index_status['url']           = $index_meta['current_sync_item']['url'] ?? ''; // Global indexables won't have a url.
			$index_status['total_items']   = $index_meta['current_sync_item']['total'];
			$index_status['slug']          = $index_meta['current_sync_item']['indexable'];
		}

		// Change method name for retrocompatibility.
		// `dashboard` is used mainly because hooks names depend on that.
		if ( ! empty( $index_status['method'] ) && 'dashboard' === $index_status['method'] ) {
			$index_status['method'] = 'web';
		}

		if ( ! empty( $index_status['method'] ) && 'web' === $index_status['method'] ) {
			$should_interrupt_sync = filter_var(
				get_transient( 'ep_sync_interrupted' ),
				FILTER_VALIDATE_BOOLEAN
			);

			$index_status['should_interrupt_sync'] = $should_interrupt_sync;
		}
	}

	return $index_status;

}

/**
 * Use the correct update option function depending on the context (multisite or not)
 *
 * @since 3.6.0
 * @param string $option   Name of the option to update.
 * @param mixed  $value    Option value.
 * @param mixed  $autoload Whether to load the option when WordPress starts up.
 * @return bool
 */
function update_option( $option, $value, $autoload = null ) {
	if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
		return \update_site_option( $option, $value );
	}
	return \update_option( $option, $value, $autoload );
}

/**
 * Use the correct get option function depending on the context (multisite or not)
 *
 * @since 3.6.0
 * @param string $option        Name of the option to get.
 * @param mixed  $default_value Default value.
 * @return mixed
 */
function get_option( $option, $default_value = false ) {
	if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
		return \get_site_option( $option, $default_value );
	}
	return \get_option( $option, $default_value );
}

/**
 * Use the correct delete option function depending on the context (multisite or not)
 *
 * @since 3.6.0
 * @param string $option Name of the option to delete.
 * @return bool
 */
function delete_option( $option ) {
	if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
		return \delete_site_option( $option );
	}
	return \delete_option( $option );
}

/**
 * Check if queries for the current request are going to be integrated with
 * ElasticPress.
 *
 * Public requests and REST API requests are integrated by default, but admin
 * requests will only be integrated in if the `ep_admin_wp_query_integration`
 * filter returns `true`, and and admin-ajax.php requests will only be
 * integrated if the `ep_ajax_wp_query_integration` filter returns `true`.
 *
 * If specific types of requests are passed, true will only be returned if the
 * current request also matches one of the passed types.
 *
 * This function is used by features to determine whether they should hook into
 * the current request.
 *
 * @param string   $context Slug of the feature that is performing the check.
 *                          Passed to the `ep_is_integrated_request` filter.
 * @param string[] $types   Which types of request to check. Any of 'admin',
 *                          'ajax', 'public', and 'rest'. Defaults to all
 *                          types.
 * @return bool Whether the current request supports ElasticPress integration
 *              and is of a given type.
 *
 * @since 3.6.0
 */
function is_integrated_request( $context, $types = [] ) {
	if ( empty( $types ) ) {
		$types = [ 'admin', 'ajax', 'public', 'rest' ];
	}

	$is_admin_request             = is_admin();
	$is_ajax_request              = wp_doing_ajax();
	$is_rest_request              = defined( 'REST_REQUEST' ) && REST_REQUEST;
	$is_integrated_admin_request  = false;
	$is_integrated_ajax_request   = false;
	$is_integrated_public_request = false;
	$is_integrated_rest_request   = false;

	if ( $is_admin_request && ! $is_ajax_request && in_array( 'admin', $types, true ) ) {

		/**
		 * Filter whether to integrate with admin queries.
		 *
		 * @hook ep_admin_wp_query_integration
		 * @param bool $integrate True to integrate.
		 * @return bool New value.
		 */
		$is_integrated_admin_request = apply_filters( 'ep_admin_wp_query_integration', false );
	}

	if ( $is_ajax_request && in_array( 'ajax', $types, true ) ) {

		/**
		 * Filter to integrate with admin ajax queries.
		 *
		 * @hook ep_ajax_wp_query_integration
		 * @param bool $integrate True to integrate.
		 * @return bool New value.
		 */
		$is_integrated_ajax_request = apply_filters( 'ep_ajax_wp_query_integration', false );
	}

	if ( $is_rest_request && in_array( 'rest', $types, true ) ) {
		$is_integrated_rest_request = true;
	}

	if ( ! $is_admin_request && ! $is_ajax_request && ! $is_rest_request && in_array( 'public', $types, true ) ) {
		$is_integrated_public_request = true;
	}

	/**
	 * Is the current request any of the supported requests.
	 */
	$is_integrated = (
		$is_integrated_admin_request ||
		$is_integrated_ajax_request ||
		$is_integrated_public_request ||
		$is_integrated_rest_request
	);

	/**
	 * Filter whether the queries for the current request should be integrated.
	 *
	 * @hook ep_is_integrated_request
	 * @param bool   $is_integrated Whether queries for the request will be
	 *                              integrated.
	 * @param string $context       Context for the original check. Usually the
	 *                              slug of the feature doing the check.
	 * @param array  $types         Which requests types are being checked.
	 * @return bool Whether queries for the request will be integrated.
	 *
	 * @since 3.6.2
	 */
	return apply_filters( 'ep_is_integrated_request', $is_integrated, $context, $types );
}

/**
 * Get asset info from extracted asset files
 *
 * @param string $slug Asset slug as defined in build/webpack configuration
 * @param string $attribute Optional attribute to get. Can be version or dependencies
 * @return string|array
 */
function get_asset_info( $slug, $attribute = null ) {
	if ( file_exists( EP_PATH . 'dist/js/' . $slug . '.asset.php' ) ) {
		$asset = require EP_PATH . 'dist/js/' . $slug . '.asset.php';
	} elseif ( file_exists( EP_PATH . 'dist/css/' . $slug . '.asset.php' ) ) {
		$asset = require EP_PATH . 'dist/css/' . $slug . '.asset.php';
	} else {
		return null;
	}

	if ( ! empty( $attribute ) && isset( $asset[ $attribute ] ) ) {
		return $asset[ $attribute ];
	}

	return $asset;
}

/**
 * Return the Sync Page URL.
 *
 * @since 4.4.0
 * @param boolean|string $do_sync Whether the link should or should not start a resync. Pass a string to store the reason of the resync.
 * @return string
 */
function get_sync_url( $do_sync = false ) : string {
	$page = 'admin.php?page=elasticpress-sync';
	if ( $do_sync ) {
		$page .= '&do_sync';
		if ( is_string( $do_sync ) ) {
			$page .= '=' . rawurlencode( $do_sync );
		}
		$page .= '&ep_sync_nonce=' . wp_create_nonce( 'ep_sync_nonce' );
	}
	return ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) ?
		network_admin_url( $page ) :
		admin_url( $page );
}

/**
 * Check if the `do_sync` parameter is set and the nonce is valid.
 *
 * @since 5.1.2
 * @return boolean
 */
function isset_do_sync_parameter() : bool {
	return isset( $_GET['do_sync'] ) && ! empty( $_GET['ep_sync_nonce'] ) && wp_verify_nonce( sanitize_key( $_GET['ep_sync_nonce'] ), 'ep_sync_nonce' );
}

/**
 * Generate a common prefix to be used while generating a request ID.
 *
 * Uses the return of `get_index_prefix()` by default.
 *
 * @since 4.5.0
 * @return string
 */
function get_request_id_base() {
	/**
	 * Filter the base of requests IDs. Uses the return of `get_index_prefix()` by default.
	 *
	 * @hook ep_request_id_base
	 * @since 4.5.0
	 * @param {string} $request_id_base Request ID base
	 * @return {string} New Request ID base
	 */
	return apply_filters( 'ep_request_id_base', str_replace( '-', '', get_index_prefix() ) );
}

/**
 * Generate a Request ID.
 *
 * The function concatenates the indices prefix to a random UUID4.
 *
 * @since 4.5.0
 * @return string
 */
function generate_request_id() : string {
	$uuid = str_replace( '-', '', wp_generate_uuid4() );

	/**
	 * Filter the ID generated to identify a request.
	 *
	 * @hook ep_request_id
	 * @since 4.5.0
	 * @param {string} $request_id Request ID. By default formed by the indices prefix and a random UUID4.
	 * @return {string} New Request ID
	 */
	return apply_filters( 'ep_request_id', get_request_id_base() . $uuid );
}

/**
 * Given an Elasticsearch response, try to find an error message.
 *
 * @since 4.6.0
 * @param mixed $response The Elasticsearch response
 * @return string
 */
function get_elasticsearch_error_reason( $response ) : string {
	if ( is_string( $response ) ) {
		return $response;
	}

	if ( ! is_array( $response ) ) {
		return var_export( $response, true ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
	}

	if ( ! empty( $response['reason'] ) ) {
		return (string) $response['reason'];
	}

	if ( ! empty( $response['result']['error'] ) && ! empty( $response['result']['error']['root_cause'][0]['reason'] ) ) {
		return (string) $response['result']['error']['root_cause'][0]['reason'];
	}

	if ( ! empty( $response['result']['errors'] ) && ! empty( $response['result']['items'] ) ) {
		$error = '';
		foreach ( $response['result']['items'] as $item ) {
			if ( ! empty( $item['index']['error']['reason'] ) ) {
				$error = $item['index']['error']['reason'];
				break;
			}
		}
		return $error;
	}

	return '';
}

/**
 * Use the correct set_transient option function depending on the context (multisite or not)
 *
 * @since 4.7.0
 * @param string $transient  Transient name. Expected to not be SQL-escaped.
 *                           Must be 172 characters or fewer in length.
 * @param mixed  $value      Transient value. Must be serializable if non-scalar.
 *                           Expected to not be SQL-escaped.
 * @param int    $expiration Optional. Time until expiration in seconds. Default 0 (no expiration).
 * @return bool True if the value was set, false otherwise.
 */
function set_transient( $transient, $value, $expiration = 0 ) {
	if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
		return \set_site_transient( $transient, $value, $expiration );
	}
	return \set_transient( $transient, $value, $expiration );
}

/**
 * Use the correct get_transient function depending on the context (multisite or not)
 *
 * @since 4.7.0
 * @param string $transient Transient name. Expected to not be SQL-escaped.
 * @return mixed Value of transient.
 */
function get_transient( $transient ) {
	if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
		return \get_site_transient( $transient );
	}
	return \get_transient( $transient );
}

/**
 * Use the correct delete_transient function depending on the context (multisite or not)
 *
 * @since 4.7.0
 * @param string $transient Transient name. Expected to not be SQL-escaped.
 * @return bool True if the transient was deleted, false otherwise.
 */
function delete_transient( $transient ) {
	if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
		return \delete_site_transient( $transient );
	}
	return \delete_transient( $transient );
}

/**
 * Whether we are in the top level admin context or not.
 *
 * In a single site, the top level admin context would be `is_admin()`,
 * in a multisite, it would be `is_network_admin()`.
 *
 * @since 5.0.0
 * @return boolean
 */
function is_top_level_admin_context() {
	$is_network = defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK;
	return $is_network ? is_network_admin() : is_admin();
}