<?php
/**
 * Jetpack Assets package.
 *
 * @package  automattic/jetpack-assets
 */

namespace Automattic\Jetpack;

use Automattic\Jetpack\Assets\Semver;
use Automattic\Jetpack\Constants as Jetpack_Constants;
use InvalidArgumentException;

/**
 * Class Assets
 */
class Assets {
	/**
	 * Holds all the scripts handles that should be loaded in a deferred fashion.
	 *
	 * @var array
	 */
	private $defer_script_handles = array();

	/**
	 * The singleton instance of this class.
	 *
	 * @var Assets
	 */
	protected static $instance;

	/**
	 * The registered textdomain mappings.
	 *
	 * @var array `array( mapped_domain => array( string target_domain, string target_type, string semver ) )`.
	 */
	private static $domain_map = array();

	/**
	 * The registered package paths, by textdomain.
	 *
	 * Separate from `$domain_map` because the two answer different questions:
	 * the map says which domain a package's strings are translated under, while
	 * this says where the package's files live — the prefix WordPress hashes to
	 * name a JS translation file. A package whose textdomain is already its
	 * plugin's has nothing to alias but still needs the path.
	 *
	 * Note the entries are keyed by domain, not by script: `downloadI18n()`
	 * prepends a domain's prefix to every bundle path looked up under it. That
	 * is only ever one package's path, so a plugin must not both bundle a
	 * package whose textdomain is the plugin's own and load its own
	 * `wp-jp-i18n-loader` bundles under that same domain — the package's prefix
	 * would be applied to the plugin's catalogs too, and they would all 404. No
	 * plugin does both today. One that needs to should give the package a
	 * distinct textdomain, the way `jetpack-backup-pkg` and
	 * `jetpack-videopress-pkg` do.
	 *
	 * @var array `array( domain => array( string semver, string path_prefix ) )`.
	 */
	private static $domain_paths = array();

	/**
	 * Constructor.
	 *
	 * Static-only class, so nothing here.
	 */
	private function __construct() {}

	// ////////////////////
	// region Async script loading

	/**
	 * Get the singleton instance of the class.
	 *
	 * @return Assets
	 */
	public static function instance() {
		if ( ! isset( self::$instance ) ) {
			self::$instance = new Assets();
		}

		return self::$instance;
	}

	/**
	 * A public method for adding the async script.
	 *
	 * @deprecated Since 2.1.0, the `strategy` feature should be used instead, with the "defer" setting.
	 *
	 * @param string $script_handle Script handle.
	 */
	public static function add_async_script( $script_handle ) {
		_deprecated_function( __METHOD__, '2.1.0' );

		wp_script_add_data( $script_handle, 'strategy', 'defer' );
	}

	/**
	 * Add an async attribute to scripts that can be loaded deferred.
	 * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script
	 *
	 * @deprecated Since 2.1.0, the `strategy` feature should be used instead.
	 *
	 * @param string $tag    The <script> tag for the enqueued script.
	 * @param string $handle The script's registered handle.
	 */
	public function script_add_async( $tag, $handle ) {
		_deprecated_function( __METHOD__, '2.1.0' );
		if ( empty( $this->defer_script_handles ) ) {
			return $tag;
		}

		if ( in_array( $handle, $this->defer_script_handles, true ) ) {
			// phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript
			return preg_replace( '/<script( [^>]*)? src=/i', '<script defer$1 src=', $tag );
		}

		return $tag;
	}

	/**
	 * A helper function that lets you enqueue scripts in an async fashion.
	 *
	 * @deprecated Since 2.1.0 - use the strategy feature instead.
	 *
	 * @param string $handle        Name of the script. Should be unique.
	 * @param string $min_path      Minimized script path.
	 * @param string $non_min_path  Full Script path.
	 * @param array  $deps           Array of script dependencies.
	 * @param bool   $ver             The script version.
	 * @param bool   $in_footer       Should the script be included in the footer.
	 */
	public static function enqueue_async_script( $handle, $min_path, $non_min_path, $deps = array(), $ver = false, $in_footer = true ) {
		_deprecated_function( __METHOD__, '2.1.0' );
		wp_enqueue_script( $handle, self::get_file_url_for_environment( $min_path, $non_min_path ), $deps, $ver, $in_footer );
		wp_script_add_data( $handle, 'strategy', 'defer' );
	}

	// endregion .

	// ////////////////////
	// region Utils

	/**
	 * Given a minified path, and a non-minified path, will return
	 * a minified or non-minified file URL based on whether SCRIPT_DEBUG is set and truthy.
	 *
	 * If $package_path is provided, then the minified or non-minified file URL will be generated
	 * relative to the root package directory.
	 *
	 * Both `$min_base` and `$non_min_base` can be either full URLs, or are expected to be relative to the
	 * root Jetpack directory.
	 *
	 * @param string $min_path     minified path.
	 * @param string $non_min_path non-minified path.
	 * @param string $package_path Optional. A full path to a file inside a package directory
	 *                             The URL will be relative to its directory. Default empty.
	 *                             Typically this is done by passing __FILE__ as the argument.
	 *
	 * @return string The URL to the file
	 * @since 1.0.3
	 * @since-jetpack 5.6.0
	 */
	public static function get_file_url_for_environment( $min_path, $non_min_path, $package_path = '' ) {
		$path = ( Jetpack_Constants::is_defined( 'SCRIPT_DEBUG' ) && Jetpack_Constants::get_constant( 'SCRIPT_DEBUG' ) )
			? $non_min_path
			: $min_path;

		/*
		 * If the path is actually a full URL, keep that.
		 * We look for a host value, since enqueues are sometimes without a scheme.
		 */
		$file_parts = wp_parse_url( $path );
		if ( ! empty( $file_parts['host'] ) ) {
			$url = $path;
		} else {
			$plugin_path = empty( $package_path ) ? Jetpack_Constants::get_constant( 'JETPACK__PLUGIN_FILE' ) : $package_path;

			$url = plugins_url( $path, $plugin_path );
		}

		/**
		 * Filters the URL for a file passed through the get_file_url_for_environment function.
		 *
		 * @since 1.0.3
		 *
		 * @package assets
		 *
		 * @param string $url The URL to the file.
		 * @param string $min_path The minified path.
		 * @param string $non_min_path The non-minified path.
		 */
		return apply_filters( 'jetpack_get_file_for_environment', $url, $min_path, $non_min_path );
	}

	/**
	 * Passes an array of URLs to wp_resource_hints.
	 *
	 * @since 1.5.0
	 *
	 * @param string|array $urls URLs to hint.
	 * @param string       $type One of the supported resource types: dns-prefetch (default), preconnect, prefetch, or prerender.
	 */
	public static function add_resource_hint( $urls, $type = 'dns-prefetch' ) {
		add_filter(
			'wp_resource_hints',
			function ( $hints, $resource_type ) use ( $urls, $type ) {
				if ( $resource_type === $type ) {
					// Type casting to array required since the function accepts a single string.
					foreach ( (array) $urls as $url ) {
						$hints[] = $url;
					}
				}
				return $hints;
			},
			10,
			2
		);
	}

	/**
	 * Serve a WordPress.com static resource via a randomized wp.com subdomain.
	 *
	 * @since 1.9.0
	 *
	 * @param string $url WordPress.com static resource URL.
	 *
	 * @return string $url
	 */
	public static function staticize_subdomain( $url ) {
		// Extract hostname from URL.
		$host = wp_parse_url( $url, PHP_URL_HOST );

		// Explode hostname on '.'.
		$exploded_host = explode( '.', $host );

		// Retrieve the name and TLD.
		if ( count( $exploded_host ) > 1 ) {
			$name = $exploded_host[ count( $exploded_host ) - 2 ];
			$tld  = $exploded_host[ count( $exploded_host ) - 1 ];
			// Rebuild domain excluding subdomains.
			$domain = $name . '.' . $tld;
		} else {
			$domain = $host;
		}
		// Array of Automattic domains.
		$domains_allowed = array( 'wordpress.com', 'wp.com' );

		// Return $url if not an Automattic domain.
		if ( ! in_array( $domain, $domains_allowed, true ) ) {
			return $url;
		}

		if ( \is_ssl() ) {
			return preg_replace( '|https?://[^/]++/|', 'https://s-ssl.wordpress.com/', $url );
		}

		/*
		 * Generate a random subdomain id by taking the modulus of the crc32 value of the URL.
		 * Valid values are 0, 1, and 2.
		 */
		$static_counter = abs( crc32( basename( $url ) ) % 3 );

		return preg_replace( '|://[^/]+?/|', "://s$static_counter.wp.com/", $url );
	}

	/**
	 * Resolve '.' and '..' components in a path or URL.
	 *
	 * @since 1.12.0
	 * @param string $path Path or URL.
	 * @return string Normalized path or URL.
	 */
	public static function normalize_path( $path ) {
		$parts = wp_parse_url( $path );
		if ( ! isset( $parts['path'] ) ) {
			return $path;
		}

		$ret  = '';
		$ret .= isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '';
		if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
			$ret .= $parts['user'] ?? '';
			$ret .= isset( $parts['pass'] ) ? ':' . $parts['pass'] : '';
			$ret .= '@';
		}
		$ret .= $parts['host'] ?? '';
		$ret .= isset( $parts['port'] ) ? ':' . $parts['port'] : '';

		$pp = explode( '/', $parts['path'] );
		if ( '' === $pp[0] ) {
			$ret .= '/';
			array_shift( $pp );
		}
		$i = 0;
		while ( $i < count( $pp ) ) { // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found
			if ( '' === $pp[ $i ] || '.' === $pp[ $i ] || 0 === $i && '..' === $pp[ $i ] ) {
				array_splice( $pp, $i, 1 );
			} elseif ( '..' === $pp[ $i ] ) {
				array_splice( $pp, --$i, 2 );
			} else {
				++$i;
			}
		}
		$ret .= implode( '/', $pp );

		$ret .= isset( $parts['query'] ) ? '?' . $parts['query'] : '';
		$ret .= isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '';

		return $ret;
	}

	// endregion .

	// ////////////////////
	// region Webpack-built script registration

	/**
	 * Register a Webpack-built script.
	 *
	 * Our Webpack-built scripts tend to need a bunch of boilerplate:
	 *  - A call to `Assets::get_file_url_for_environment()` for possible debugging.
	 *  - A call to `wp_register_style()` for extracted CSS, possibly with detection of RTL.
	 *  - Loading of dependencies and version provided by `@wordpress/dependency-extraction-webpack-plugin`.
	 *  - Avoiding WPCom's broken minifier.
	 *
	 * This wrapper handles all of that.
	 *
	 * @since 1.12.0
	 * @since 2.1.0 Add a new `strategy` option to leverage WP >= 6.3 script strategy feature. The `async` option is deprecated.
	 * @param string $handle      Name of the script. Should be unique across both scripts and styles.
	 * @param string $path        Minimized script path.
	 * @param string $relative_to File that `$path` is relative to. Pass `__FILE__`.
	 * @param array  $options     Additional options:
	 *  - `asset_path`:       (string|null) `.asset.php` to load. Default is to base it on `$path`.
	 *  - `async`:            (bool) Set true to register the script as deferred, like `Assets::enqueue_async_script()`. Deprecated in favor of `strategy`.
	 *  - `css_dependencies`: (string[]) Additional style dependencies to queue.
	 *  - `css_path`:         (string|null) `.css` to load. Default is to base it on `$path`.
	 *  - `dependencies`:     (string[]) Additional script dependencies to queue.
	 *  - `enqueue`:          (bool) Set true to enqueue the script immediately.
	 *  - `in_footer`:        (bool) Set true to register script for the footer.
	 *  - `media`:            (string) Media for the css file. Default 'all'.
	 *  - `minify`:           (bool|null) Set true to pass `minify=true` in the query string, or `null` to suppress the normal `minify=false`.
	 *  - `nonmin_path`:      (string) Non-minified script path.
	 *  - `strategy`:         (string) Specify a script strategy to use, eg. `defer` or `async`. Default is `""`.
	 *  - `textdomain`:       (string) Text domain for the script. Required if the script depends on wp-i18n.
	 *  - `version`:          (string) Override the version from the `asset_path` file.
	 * @phan-param array{asset_path?:?string,async?:bool,css_dependencies?:string[],css_path?:?string,dependencies?:string[],enqueue?:bool,in_footer?:bool,media?:string,minify?:?bool,nonmin_path?:string,strategy?:string,textdomain?:string,version?:string} $options
	 * @throws \InvalidArgumentException If arguments are invalid.
	 */
	public static function register_script( $handle, $path, $relative_to, array $options = array() ) {
		if ( substr( $path, -3 ) !== '.js' ) {
			throw new \InvalidArgumentException( '$path must end in ".js"' );
		}

		if ( isset( $options['async'] ) ) {
			_deprecated_argument( __METHOD__, '2.1.0', 'The `async` option is deprecated in favor of `strategy`' );
		}

		$dir      = dirname( $relative_to );
		$base     = substr( $path, 0, -3 );
		$options += array(
			'asset_path'       => "$base.asset.php",
			'async'            => false,
			'css_dependencies' => array(),
			'css_path'         => "$base.css",
			'dependencies'     => array(),
			'enqueue'          => false,
			'in_footer'        => false,
			'media'            => 'all',
			'minify'           => false,
			'strategy'         => '',
			'textdomain'       => null,
		);
		'@phan-var array{asset_path:?string,async:bool,css_dependencies:string[],css_path:?string,dependencies:string[],enqueue:bool,in_footer:bool,media:string,minify:?bool,nonmin_path?:string,strategy:string,textdomain:string,version?:string} $options'; // Phan gets confused by the array addition.

		if ( is_string( $options['css_path'] ) && $options['css_path'] !== '' && substr( $options['css_path'], -4 ) !== '.css' ) {
			throw new \InvalidArgumentException( '$options[\'css_path\'] must end in ".css"' );
		}

		if ( isset( $options['nonmin_path'] ) ) {
			$url = self::get_file_url_for_environment( $path, $options['nonmin_path'], $relative_to );
		} else {
			$url = plugins_url( $path, $relative_to );
		}
		$url = self::normalize_path( $url );
		if ( null !== $options['minify'] ) {
			$url = add_query_arg( 'minify', $options['minify'] ? 'true' : 'false', $url );
		}

		if ( $options['asset_path'] && file_exists( "$dir/{$options['asset_path']}" ) ) {
			$asset                       = require "$dir/{$options['asset_path']}"; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath
			$options['dependencies']     = array_merge( $asset['dependencies'], $options['dependencies'] );
			$options['css_dependencies'] = array_merge(
				array_filter(
					$asset['dependencies'],
					function ( $d ) {
						return wp_style_is( $d, 'registered' );
					}
				),
				$options['css_dependencies']
			);
			$ver                         = $options['version'] ?? $asset['version'];
		} else {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			$ver = $options['version'] ?? @filemtime( "$dir/$path" );
		}

		if ( $options['async'] && '' === $options['strategy'] ) { // Handle the deprecated `async` option
			$options['strategy'] = 'defer';
		}
		wp_register_script(
			$handle,
			$url,
			$options['dependencies'],
			$ver,
			array(
				'in_footer' => $options['in_footer'],
				'strategy'  => $options['strategy'],
			)
		);

		if ( $options['textdomain'] ) {
			// phpcs:ignore Jetpack.Functions.I18n.DomainNotLiteral
			wp_set_script_translations( $handle, $options['textdomain'] );
		} elseif ( in_array( 'wp-i18n', $options['dependencies'], true ) ) {
			_doing_it_wrong(
				__METHOD__,
				/* translators: %s is the script handle. */
				esc_html( sprintf( __( 'Script "%s" depends on wp-i18n but does not specify "textdomain"', 'jetpack-assets' ), $handle ) ),
				''
			);
		}

		if ( is_string( $options['css_path'] ) && $options['css_path'] !== '' && file_exists( "$dir/{$options['css_path']}" ) ) {
			$csspath = $options['css_path'];
			if ( is_rtl() ) {
				$rtlcsspath = substr( $csspath, 0, -4 ) . '.rtl.css';
				if ( file_exists( "$dir/$rtlcsspath" ) ) {
					$csspath = $rtlcsspath;
				}
			}

			$url = self::normalize_path( plugins_url( $csspath, $relative_to ) );
			if ( null !== $options['minify'] ) {
				$url = add_query_arg( 'minify', $options['minify'] ? 'true' : 'false', $url );
			}
			wp_register_style( $handle, $url, $options['css_dependencies'], $ver, $options['media'] );
			wp_script_add_data( $handle, 'Jetpack::Assets::hascss', true );
		} else {
			wp_script_add_data( $handle, 'Jetpack::Assets::hascss', false );
		}

		if ( $options['enqueue'] ) {
			self::enqueue_script( $handle );
		}
	}

	/**
	 * Enqueue a script registered with `Assets::register_script`.
	 *
	 * @since 1.12.0
	 * @param string $handle       Name of the script. Should be unique across both scripts and styles.
	 */
	public static function enqueue_script( $handle ) {
		wp_enqueue_script( $handle );
		if ( wp_scripts()->get_data( $handle, 'Jetpack::Assets::hascss' ) ) {
			wp_enqueue_style( $handle );
		}
	}

	/**
	 * 'wp_default_scripts' action handler.
	 *
	 * This registers the `wp-jp-i18n-loader` script for use by Webpack bundles built with
	 * `@automattic/i18n-loader-webpack-plugin`.
	 *
	 * @since 1.14.0
	 * @param \WP_Scripts $wp_scripts WP_Scripts instance.
	 */
	public static function wp_default_scripts_hook( $wp_scripts ) {
		$data = array(
			'baseUrl'     => false,
			'locale'      => determine_locale(),
			'domainMap'   => array(),
			'domainPaths' => array(),
		);

		$lang_dir    = Jetpack_Constants::get_constant( 'WP_LANG_DIR' );
		$content_dir = Jetpack_Constants::get_constant( 'WP_CONTENT_DIR' );
		$abspath     = Jetpack_Constants::get_constant( 'ABSPATH' );

		// Note: str_starts_with() is not used here, as wp-includes/compat.php may not be loaded at this point.
		if ( strpos( $lang_dir, $content_dir ) === 0 ) {
			$data['baseUrl'] = content_url( substr( trailingslashit( $lang_dir ), strlen( trailingslashit( $content_dir ) ) ) );
		} elseif ( strpos( $lang_dir, $abspath ) === 0 ) {
			$data['baseUrl'] = site_url( substr( trailingslashit( $lang_dir ), strlen( untrailingslashit( $abspath ) ) ) );
		}

		foreach ( self::$domain_map as $from => list( $to, $type ) ) {
			$data['domainMap'][ $from ] = ( 'core' === $type ? '' : "{$type}/" ) . $to;
		}
		foreach ( self::$domain_paths as $from => list( , $path ) ) {
			if ( '' !== $path ) {
				$data['domainPaths'][ $from ] = trailingslashit( $path );
			}
		}

		/**
		 * Filters the i18n state data for use by Webpack bundles built with
		 * `@automattic/i18n-loader-webpack-plugin`.
		 *
		 * @since 1.14.0
		 * @package assets
		 * @param array $data The state data to generate. Expected fields are:
		 *  - `baseUrl`: (string|false) The URL to the languages directory. False if no URL could be determined.
		 *  - `locale`: (string) The locale for the page.
		 *  - `domainMap`: (string[]) A mapping from Composer package textdomains to the corresponding
		 *    `plugins/textdomain` or `themes/textdomain` (or core `textdomain`, but that's unlikely).
		 *  - `domainPaths`: (string[]) A mapping from Composer package textdomains to the corresponding package
		 *     paths.
		 */
		$data = apply_filters( 'jetpack_i18n_state', $data );

		// Can't use self::register_script(), this action is called too early.
		if ( file_exists( __DIR__ . '/../build/i18n-loader.asset.php' ) ) {
			$path  = '../build/i18n-loader.js';
			$asset = require __DIR__ . '/../build/i18n-loader.asset.php';
		} else {
			$path  = 'js/i18n-loader.js';
			$asset = array(
				'dependencies' => array( 'wp-i18n' ),
				'version'      => filemtime( __DIR__ . "/$path" ),
			);
		}
		$url = self::normalize_path( plugins_url( $path, __FILE__ ) );
		$url = add_query_arg( 'minify', 'true', $url );

		$handle = 'wp-jp-i18n-loader';

		$wp_scripts->add( $handle, $url, $asset['dependencies'], $asset['version'] );

		// Ensure the script is loaded in the footer and deferred.
		$wp_scripts->add_data( $handle, 'group', 1 );

		if ( ! is_array( $data ) ||
			! isset( $data['baseUrl'] ) || ! ( is_string( $data['baseUrl'] ) || false === $data['baseUrl'] ) ||
			! isset( $data['locale'] ) || ! is_string( $data['locale'] ) ||
			! isset( $data['domainMap'] ) || ! is_array( $data['domainMap'] ) ||
			! isset( $data['domainPaths'] ) || ! is_array( $data['domainPaths'] )
		) {
			$wp_scripts->add_inline_script( $handle, 'console.warn( "I18n state deleted by jetpack_i18n_state hook" );' );
		} elseif ( ! $data['baseUrl'] ) {
			$wp_scripts->add_inline_script( $handle, 'console.warn( "Failed to determine languages base URL. Is WP_LANG_DIR in the WordPress root?" );' );
		} else {
			$data['domainMap']   = (object) $data['domainMap']; // Ensure it becomes a json object.
			$data['domainPaths'] = (object) $data['domainPaths']; // Ensure it becomes a json object.
			$wp_scripts->add_inline_script( $handle, 'wp.jpI18nLoader.state = ' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';' );
		}

		// Deprecated state module: Depend on wp-i18n to ensure global `wp` exists and because anything needing this will need that too.
		$wp_scripts->add( 'wp-jp-i18n-state', false, array( 'wp-deprecated', $handle ) );
		$wp_scripts->add_inline_script( 'wp-jp-i18n-state', 'wp.deprecated( "wp-jp-i18n-state", { alternative: "wp-jp-i18n-loader" } );' );
		$wp_scripts->add_inline_script( 'wp-jp-i18n-state', 'wp.jpI18nState = wp.jpI18nLoader.state;' );
	}

	// endregion .

	// ////////////////////
	// region Textdomain aliasing

	/**
	 * Register a textdomain alias.
	 *
	 * Composer packages included in plugins will likely not use the textdomain of the plugin, while
	 * WordPress's i18n infrastructure will include the translations in the plugin's domain. This
	 * allows for mapping the package's domain to the plugin's.
	 *
	 * Since multiple plugins may use the same package, we include the package's version here so
	 * as to choose the most recent translations (which are most likely to match the package
	 * selected by jetpack-autoloader).
	 *
	 * @since 1.15.0
	 * @param string $from Domain to alias.
	 * @param string $to Domain to alias it to.
	 * @param string $totype What is the target of the alias: 'plugins', 'themes', or 'core'.
	 * @param string $ver Version of the `$from` domain.
	 * @param string $path Path to prepend when lazy-loading from JavaScript.
	 * @throws InvalidArgumentException If arguments are invalid.
	 */
	public static function alias_textdomain( $from, $to, $totype, $ver, $path = '' ) {
		if ( ! in_array( $totype, array( 'plugins', 'themes', 'core' ), true ) ) {
			throw new InvalidArgumentException( 'Type must be "plugins", "themes", or "core"' );
		}

		if (
			did_action( 'wp_default_scripts' ) &&
			// Don't complain during plugin activation.
			! defined( 'WP_SANDBOX_SCRAPING' )
		) {
			_doing_it_wrong(
				__METHOD__,
				sprintf(
					/* translators: 1: wp_default_scripts. 2: Name of the domain being aliased. */
					esc_html__( 'Textdomain aliases should be registered before the %1$s hook. This notice was triggered by the %2$s domain.', 'jetpack-assets' ),
					'<code>wp_default_scripts</code>',
					'<code>' . esc_html( $from ) . '</code>'
				),
				''
			);
		}

		// Where the package lives is needed for JS translation files whether or
		// not its domain is aliased, so it is recorded before the self-alias
		// check below.
		if (
			empty( self::$domain_paths[ $from ] ) ||
			Semver::compare( $ver, self::$domain_paths[ $from ][0] ) > 0
		) {
			self::$domain_paths[ $from ] = array( $ver, $path );
		}

		// A self-alias would make filter_gettext() re-translate into the same
		// domain, recursing infinitely on any untranslated string (a package
		// textdomain can collide with its containing plugin's slug).
		if ( $from === $to ) {
			return;
		}

		if ( empty( self::$domain_map[ $from ] ) ) {
			self::init_domain_map_hooks( $from, array() === self::$domain_map );
			self::$domain_map[ $from ] = array( $to, $totype, $ver );
		} elseif ( Semver::compare( $ver, self::$domain_map[ $from ][2] ) > 0 ) {
			self::$domain_map[ $from ] = array( $to, $totype, $ver );
		}
	}

	/**
	 * Register textdomain aliases from a mapping file.
	 *
	 * The mapping file is simply a PHP file that returns an array
	 * with the following properties:
	 *  - 'domain': String, `$to`
	 *  - 'type': String, `$totype`
	 *  - 'packages': Array, mapping `$from` to `array( 'path' => $path, 'ver' => $ver )` (or to the string `$ver` for back compat).
	 *  - 'paths': Array, same shape, for packages whose textdomain is already
	 *    `$to`. Those must not be aliased — that would recurse — but their
	 *    paths are still needed to locate their JavaScript translations.
	 *
	 * @since 1.15.0
	 * @param string $file Mapping file.
	 */
	public static function alias_textdomains_from_file( $file ) {
		$data = require $file;
		foreach ( $data['packages'] as $from => $fromdata ) {
			if ( ! is_array( $fromdata ) ) {
				$fromdata = array(
					'path' => '',
					'ver'  => $fromdata,
				);
			}
			self::alias_textdomain( $from, $data['domain'], $data['type'], $fromdata['ver'], $fromdata['path'] );
		}
		// Aliasing a domain to itself is a no-op that `alias_textdomain()`
		// declines, leaving just the path registration these entries are for.
		foreach ( $data['paths'] ?? array() as $from => $fromdata ) {
			self::alias_textdomain( $from, $from, $data['type'], $fromdata['ver'], $fromdata['path'] );
		}
	}

	/**
	 * Register the hooks for textdomain aliasing.
	 *
	 * @param string $domain Domain to alias.
	 * @param bool   $firstcall If this is the first call.
	 */
	private static function init_domain_map_hooks( $domain, $firstcall ) {
		// If WordPress's plugin API is available already, use it. If not,
		// drop data into `$wp_filter` for `WP_Hook::build_preinitialized_hooks()`.
		if ( function_exists( 'add_filter' ) ) {
			$add_filter = 'add_filter';
		} else {
			$add_filter = function ( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) {
				global $wp_filter;
				// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
				$wp_filter[ $hook_name ][ $priority ][] = array(
					'accepted_args' => $accepted_args,
					'function'      => $callback,
				);
			};
		}

		$add_filter( "gettext_{$domain}", array( self::class, 'filter_gettext' ), 10, 3 );
		$add_filter( "ngettext_{$domain}", array( self::class, 'filter_ngettext' ), 10, 5 );
		$add_filter( "gettext_with_context_{$domain}", array( self::class, 'filter_gettext_with_context' ), 10, 4 );
		$add_filter( "ngettext_with_context_{$domain}", array( self::class, 'filter_ngettext_with_context' ), 10, 6 );
		if ( $firstcall ) {
			$add_filter( 'load_script_translation_file', array( self::class, 'filter_load_script_translation_file' ), 10, 3 );
		}
	}

	/**
	 * Filter for `gettext`.
	 *
	 * @since 1.15.0
	 * @param string $translation Translated text.
	 * @param string $text Text to translate.
	 * @param string $domain Text domain.
	 * @return string Translated text.
	 */
	public static function filter_gettext( $translation, $text, $domain ) {
		if ( $translation === $text ) {
			// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
			$newtext = __( $text, self::$domain_map[ $domain ][0] );
			if ( $newtext !== $text ) {
				return $newtext;
			}
		}
		return $translation;
	}

	/**
	 * Filter for `ngettext`.
	 *
	 * @since 1.15.0
	 * @param string $translation Translated text.
	 * @param string $single The text to be used if the number is singular.
	 * @param string $plural The text to be used if the number is plural.
	 * @param int    $number The number to compare against to use either the singular or plural form.
	 * @param string $domain Text domain.
	 * @return string Translated text.
	 */
	public static function filter_ngettext( $translation, $single, $plural, $number, $domain ) {
		if ( $translation === $single || $translation === $plural ) {
			// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
			$translation = _n( $single, $plural, $number, self::$domain_map[ $domain ][0] );
		}
		return $translation;
	}

	/**
	 * Filter for `gettext_with_context`.
	 *
	 * @since 1.15.0
	 * @param string $translation Translated text.
	 * @param string $text Text to translate.
	 * @param string $context Context information for the translators.
	 * @param string $domain Text domain.
	 * @return string Translated text.
	 */
	public static function filter_gettext_with_context( $translation, $text, $context, $domain ) {
		if ( $translation === $text ) {
			// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
			$translation = _x( $text, $context, self::$domain_map[ $domain ][0] );
		}
		return $translation;
	}

	/**
	 * Filter for `ngettext_with_context`.
	 *
	 * @since 1.15.0
	 * @param string $translation Translated text.
	 * @param string $single The text to be used if the number is singular.
	 * @param string $plural The text to be used if the number is plural.
	 * @param int    $number The number to compare against to use either the singular or plural form.
	 * @param string $context Context information for the translators.
	 * @param string $domain Text domain.
	 * @return string Translated text.
	 */
	public static function filter_ngettext_with_context( $translation, $single, $plural, $number, $context, $domain ) {
		if ( $translation === $single || $translation === $plural ) {
			// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
			$translation = _nx( $single, $plural, $number, $context, self::$domain_map[ $domain ][0] );
		}
		return $translation;
	}

	/**
	 * Filter for `load_script_translation_file`.
	 *
	 * @since 1.15.0
	 * @param string|false $file Path to the translation file to load. False if there isn't one.
	 * @param string       $handle Name of the script to register a translation domain to.
	 * @param string       $domain The text domain.
	 */
	public static function filter_load_script_translation_file( $file, $handle, $domain ) {
		if ( false !== $file && isset( self::$domain_map[ $domain ] ) && ! is_readable( $file ) ) {
			// Determine the part of the filename after the domain.
			$suffix = basename( $file );
			$l      = strlen( $domain );
			if ( substr( $suffix, 0, $l ) !== $domain || '-' !== $suffix[ $l ] ) {
				return $file;
			}
			$suffix   = substr( $suffix, $l );
			$lang_dir = Jetpack_Constants::get_constant( 'WP_LANG_DIR' );

			// Look for replacement files.
			list( $newdomain, $type ) = self::$domain_map[ $domain ];
			$newfile                  = $lang_dir . ( 'core' === $type ? '/' : "/{$type}/" ) . $newdomain . $suffix;
			if ( is_readable( $newfile ) ) {
				return $newfile;
			}
		}
		return $file;
	}

	// endregion .
}

// Enable section folding in vim:
// vim: foldmarker=//\ region,//\ endregion foldmethod=marker
// .
