<?php
namespace WP_Rocket\Busting;

use WP_Rocket\Logger\Logger;

/**
 * Manages the cache busting of the Facebook Pixel files.
 *
 * @since  3.2
 * @author Grégory Viguier
 */
class Facebook_Pickles {

	/**
	 * Regex pattern to capture a locale.
	 *
	 * @var    string
	 * @since  3.2
	 * @author Grégory Viguier
	 */
	const LOCALE_CAPTURE = '(?<locale>[a-zA-Z_-]+)';

	/**
	 * Regex pattern to capture a version.
	 *
	 * @var    string
	 * @since  3.2
	 * @author Grégory Viguier
	 */
	const VERSION_CAPTURE = '(?<version>[\d\.]+)';

	/**
	 * Regex pattern to capture an app ID.
	 *
	 * @var    string
	 * @since  3.2
	 * @author Grégory Viguier
	 */
	const APP_ID_CAPTURE = '(?<app_id>\d+)';

	/**
	 * Cache busting files base path.
	 *
	 * @var    string
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 */
	private $busting_path;

	/**
	 * Cache busting base URL.
	 *
	 * @var    string
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 */
	private $busting_url;

	/**
	 * Main file URL (remote).
	 * %s is a locale like "en_US".
	 *
	 * @var    string
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 */
	private $main_file_url = 'https://connect.facebook.net/%s/fbevents.js';

	/**
	 * Main file name (local).
	 * %s is like "{{locale}}-{{version}}".
	 *
	 * @var    string
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 */
	private $main_file_name = 'fbpix-events-%s.js';

	/**
	 * Config file URL (remote).
	 * %d is an app ID (a number), %s is a version like "2.8.30".
	 * The "r" argument is the release segment: it is considered "stable".
	 *
	 * @var    string
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 */
	private $config_file_url = 'https://connect.facebook.net/signals/config/%s?v=%s&r=stable';

	/**
	 * Config file name (local).
	 * %s is like "{{app_id}}-{{version}}".
	 *
	 * @var    string
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 */
	private $config_file_name = 'fbpix-config-%s.js';

	/**
	 * Plugins file URL (remote).
	 * 1st %s is a plugin name like "identity", 2nd %s is a version like "2.8.30".
	 *
	 * @var    string
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 */
	private $plugins_file_url = 'https://connect.facebook.net/signals/plugins/%s?v=%s';

	/**
	 * Plugins file name (local).
	 * %s is like "{{plugin_name}}-{{version}}".
	 *
	 * @var    string
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 */
	private $plugins_file_name = 'fbpix-plugin-%s.js';

	/**
	 * Flag to track the replacement.
	 *
	 * @var    bool
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 */
	private $is_replaced = false;

	/**
	 * Constructor.
	 *
	 * @since  3.2
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param string $busting_path Path to the busting directory.
	 * @param string $busting_url  URL of the busting directory.
	 */
	public function __construct( $busting_path, $busting_url ) {
		/** Warning: all file names and script URLs are dynamic, and must be run through sprintf(). */
		$this->busting_path = $busting_path . 'facebook-tracking/';
		$this->busting_url  = $busting_url . 'facebook-tracking/';
	}

	/** ----------------------------------------------------------------------------------------- */
	/** MAIN PROCESS ============================================================================ */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Perform the URL replacement process.
	 *
	 * @since  3.2
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  string $html HTML contents.
	 * @return string       HTML contents.
	 */
	public function replace_url( $html ) {
		$this->is_replaced = false;

		$tags = $this->find_tags( $html );

		if ( ! $tags ) {
			return $html;
		}

		Logger::info(
			'FACEBOOK PIXEL CACHING PROCESS STARTED.',
			[
				'fb pixel',
				'tag' => $tags['tag_to_search'],
			]
		);

		$all_files = [];

		/**
		 * Fetch the main file: https://connect.facebook.net/{{locale}}/fbevents.js.
		 */
		$version       = $this->get_most_recent_local_version();
		$locale        = $this->get_locale_from_url( $tags['tag_to_search'] );
		$main_file_url = $this->get_main_file_url( $locale );

		if ( $version ) {
			// At least 1 main file exists locally (but maybe not in the right locale).
			$main_file_path     = $this->get_busting_file_path( $locale, $version );
			$main_file_contents = $this->get_file_contents( $main_file_path, $main_file_url );
		} else {
			// No cached files yet.
			$main_file_contents = $this->get_remote_contents( $main_file_url );
		}

		if ( ! $main_file_contents ) {
			return $html;
		}

		/**
		 * Grab some data from the main file and the inline tag: app_id and version.
		 */
		$variables = $this->get_variables( $main_file_contents, $tags['tag_to_search'] );

		if ( ! $variables ) {
			return $html;
		}

		if ( ! $version ) {
			// The local file doesn't exist yet, so we couldn't get its version (and so, can't know its path yet) until we fetch a fresh copy.
			$main_file_path = $this->get_busting_file_path( $locale, $variables['version'] );
		}

		$all_files[] = $main_file_path;
		unset( $version );

		/**
		 * Fetch the config file: https://connect.facebook.net/signals/config/{{app_id}}?v={{version}}&r={{release_segment​}}.
		 */
		$config_file_path = $this->get_config_file_path( $variables );

		if ( ! $config_file_path ) {
			return $html;
		}

		$all_files[] = $config_file_path;

		/**
		 * Fetch all plugin files: https://connect.facebook.net/signals/plugins/{{pluginName}}.js?v={{version}}​.
		 */
		$plugin_file_paths = $this->get_plugin_file_paths( $variables );

		if ( ! $plugin_file_paths ) {
			return $html;
		}

		$all_files = array_merge( $all_files, $plugin_file_paths );

		/**
		 * Modify the main file contents.
		 */
		$busting_file_url   = $this->get_busting_file_url( $locale, $variables['version'] );
		$busting_dir_url    = dirname( $busting_file_url ) . '/';
		$main_file_contents = $this->replace_main_file_contents( $main_file_contents, $busting_dir_url );

		if ( ! $main_file_contents ) {
			return $html;
		}

		/**
		 * Save all the changes to the main file.
		 */
		$updated = $this->update_file_contents( $main_file_path, $main_file_contents );

		if ( ! $updated ) {
			return $html;
		}

		/**
		 * Finally, replace the main file URL by the local one in the inline script tag.
		 */
		$replace_tag = preg_replace( '@(?:https?:)?//connect\.facebook\.net/[a-zA-Z_-]+/fbevents\.js@i', $busting_file_url, $tags['tag_to_replace'], -1, $count );

		if ( ! $count || false === strpos( $html, $tags['tag_to_replace'] ) ) {
			Logger::error( 'The local file URL could not be replaced in the page contents.', [ 'fb pixel' ] );
			return $html;
		}

		$html = str_replace( $tags['tag_to_replace'], $replace_tag, $html );

		$this->is_replaced = true;

		/**
		 * Triggered once the Facebook pixel URL has been replaced in the page contents.
		 *
		 * @since  3.2
		 * @author Grégory Viguier
		 *
		 * @param string $busting_file_url URL of the local main file.
		 * @param array  $all_files        An array of all file paths.
		 */
		do_action( 'rocket_after_facebook_pixel_url_replaced', $busting_file_url, $all_files );

		Logger::info(
			'Facebook pixel caching process succeeded.',
			[
				'fb pixel',
				'files' => $all_files,
			]
		);

		return $html;
	}

	/**
	 * Tell if the replacement was sucessful or not.
	 *
	 * @since  3.2
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @return bool
	 */
	public function is_replaced() {
		return $this->is_replaced;
	}

	/** ----------------------------------------------------------------------------------------- */
	/** GRAB/MANIPULATE DATA IN CONTENTS ======================================================== */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Search for elements in the DOM.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $html HTML contents.
	 * @return array|bool   {
	 *     An array on success, described as below. False if nothing is found.
	 *
	 *     @type string $tag_to_replace The script tag that contains the facebook.net URL: this is the tag that will be replaced in the page HTML.
	 *     @type string $tag_to_search  It contains both app ID and facebook.net URL: this is what will be searched in for this data.
	 *
	 *     When the app ID and the URL are in the same tag, $tag_to_replace and $tag_to_search are the same.
	 * }
	 */
	private function find_tags( $html ) {
		preg_match_all( '@<script[^>]*?>(.*)</script>@Umsi', $html, $matches, PREG_SET_ORDER );

		if ( empty( $matches ) ) {
			return false;
		}

		$tags = [
			'app_id' => [],
			'url'    => [],
			'both'   => [],
		];

		foreach ( $matches as $match ) {
			list( $tag, $script ) = $match;

			if ( ! trim( $script ) ) {
				continue;
			}

			$has_app_id = false;
			$has_url    = false;

			if ( preg_match( '@fbq\s*\(\s*["\']init["\']\s*,\s*["\']' . self::APP_ID_CAPTURE . '["\']@', $script, $matches_init ) ) {
				if ( (int) $matches_init['app_id'] > 0 ) {
					$has_app_id = true;
				}
			}

			$has_url = (bool) $this->get_locale_from_url( $script );

			if ( $has_app_id && $has_url ) {
				// OK we have both.
				$tags['both'] = $tag;
				break;
			}

			if ( $has_app_id ) {
				$tags['app_id'] = $tag;

				if ( $tags['url'] ) {
					// OK we have both.
					break;
				}
			} elseif ( $has_url ) {
				$tags['url'] = $tag;

				if ( $tags['app_id'] ) {
					// OK we have both.
					break;
				}
			}
		}

		if ( ! empty( $tags['both'] ) ) {
			return [
				'tag_to_replace' => $tags['both'],
				'tag_to_search'  => $tags['both'],
			];
		}

		if ( ! empty( $tags['app_id'] ) && ! empty( $tags['url'] ) ) {
			return [
				'tag_to_replace' => $tags['url'],
				'tag_to_search'  => $tags['url'] . $tags['app_id'],
			];
		}

		return false;
	}

	/**
	 * Get some values from the main file and the inline script contents.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $main_file_contents Main file contents.
	 * @param  string $tag_contents       Inline script contents.
	 * @return array|bool {
	 *     An array of values. False on failure.
	 *
	 *     @type string $app_id  The app ID.
	 *     @type string $version The file version.
	 * }
	 */
	private function get_variables( $main_file_contents = null, $tag_contents = null ) {
		$variables = [];

		if ( isset( $tag_contents ) ) {
			// Retrieve the app ID from the tag contents.
			preg_match( '@fbq\s*\(\s*["\']init["\']\s*,\s*["\']' . self::APP_ID_CAPTURE . '["\']@', $tag_contents, $matches );

			if ( empty( $matches['app_id'] ) ) {
				Logger::error( 'The app ID could not be retrieved from the inline script contents.', [ 'fb pixel' ] );
				return false;
			}

			$variables['app_id'] = $matches['app_id'];
		}

		if ( isset( $main_file_contents ) ) {
			// Retrieve the version from the main file contents.
			preg_match( '@fbq\.version\s*=\s*["\']' . self::VERSION_CAPTURE . '["\']\s*;@', $main_file_contents, $matches );

			if ( empty( $matches['version'] ) ) {
				Logger::error( 'The version could not be retrieved from the main file contents.', [ 'fb pixel' ] );
				return false;
			}

			$variables['version'] = $matches['version'];
		}

		return $variables;
	}

	/**
	 * Perform some replacements in the main file contents. Will be replaced:
	 * - the CDN_BASE_URL value,
	 * - the config file URL,
	 * - the plugins file URL.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $main_file_contents The file contents.
	 * @param  string $busting_dir_url    URL of the folder containing the files.
	 * @return string|bool                The new contents on success. False on failure.
	 */
	private function replace_main_file_contents( $main_file_contents, $busting_dir_url ) {
		/**
		 * Replace the CDN_BASE_URL value.
		 * From: CDN_BASE_URL:"https://connect.facebook.net/"
		 * To:   CDN_BASE_URL:"https://example.com/wp-content/cache/busting/facebook-tracking/"
		 */
		$replacement = 'CDN_BASE_URL:"' . $busting_dir_url . '"';

		if ( ! strpos( $main_file_contents, $replacement ) ) {
			$main_file_contents = preg_replace( '@CDN_BASE_URL\s*:\s*["\'][^"\']+["\']@', $replacement, $main_file_contents, -1, $count );

			if ( ! $count ) {
				Logger::error( 'The CDN_BASE_URL could not be replaced in the main file contents.', [ 'fb pixel' ] );
				return false;
			}
		}

		/**
		 * Replace the config file URL (https://connect.facebook.net/signals/config/{{app_id}}?v={{version}}&r={{release_segment​}}).
		 * From: CDN_BASE_URL+"signals/config/"+a+"?v="+b+"&r="+c
		 * To:   CDN_BASE_URL+"fbpix-config-"+a+"-"+b+".js" (the release segment is not taken into account, we consider it "stable")
		 */
		$replacement_pattern = $this->escape_file_name( $this->config_file_name );
		$replacement_pattern = sprintf( $replacement_pattern, '"\+[a-zA-Z._]+\+"\-"\+[a-zA-Z._]+\+"' );
		$replacement_pattern = 'CDN_BASE_URL\+"' . $replacement_pattern . '"';

		if ( ! preg_match( '/' . $replacement_pattern . '/', $main_file_contents ) ) {
			$pattern            = '@CDN_BASE_URL\s*\+\s*["\']signals/config/["\']\s*\+\s*([a-zA-Z._]+)\s*\+\s*["\']\?v=["\']\s*\+\s*([a-zA-Z._]+)\s*\+\s*["\']&r=["\']\s*\+\s*[a-zA-Z._]+@';
			$replacement        = 'CDN_BASE_URL+"' . sprintf( $this->config_file_name, '"+$1+"-"+$2+"' ) . '"';
			$main_file_contents = preg_replace( $pattern, $replacement, $main_file_contents, -1, $count );

			if ( ! $count ) {
				Logger::error( 'The config file URL could not be replaced in the main file contents.', [ 'fb pixel' ] );
				return false;
			}
		}

		/**
		 * Replace the plugins file URL (https://connect.facebook.net/signals/plugins/{{plugin_name}}.js?v={{version}}​).
		 * From: CDN_BASE_URL+"signals/plugins/"+b+".js?v="+a.version
		 * To  : CDN_BASE_URL+"fbpix-plugin-"+b+"-"+a.version+".js"
		 */
		$replacement_pattern = $this->escape_file_name( $this->plugins_file_name );
		$replacement_pattern = sprintf( $replacement_pattern, '"\+[a-zA-Z._]+\+"-"\+[a-zA-Z._]+\+"' );
		$replacement_pattern = 'CDN_BASE_URL\+"' . $replacement_pattern . '"';

		if ( ! preg_match( '/' . $replacement_pattern . '/', $main_file_contents ) ) {
			$pattern            = '@CDN_BASE_URL\s*\+\s*["\']signals/plugins/["\']\s*\+\s*([a-zA-Z._]+)\s*\+\s*["\']\.js\?v=["\']\s*\+\s*([a-zA-Z._]+)@';
			$replacement        = 'CDN_BASE_URL+"' . sprintf( $this->plugins_file_name, '"+$1+"-"+$2+"' ) . '"';
			$main_file_contents = preg_replace( $pattern, $replacement, $main_file_contents, -1, $count );

			if ( ! $count ) {
				Logger::error( 'The plugins file URL could not be replaced in the main file contents.', [ 'fb pixel' ] );
				return false;
			}
		}

		return $main_file_contents;
	}

	/** ----------------------------------------------------------------------------------------- */
	/** UPDATE/SAVE A LOCAL FILE ================================================================ */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Save the contents of a URL into a local file if it doesn't exist yet.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $url  URL to get the contents from.
	 * @param  string $path Path to the file that will store the URL contents.
	 * @return bool         True on success. False on failure.
	 */
	private function maybe_save( $url, $path ) {
		$filesystem = \rocket_direct_filesystem();

		if ( $filesystem->exists( $path ) ) {
			// If a previous version is present, keep it.
			return true;
		}

		return (bool) $this->save( $url, $path );
	}

	/**
	 * Save the contents of a URL into a local file.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $url  URL to get the contents from.
	 * @param  string $path Path to the file that will store the URL contents.
	 * @return string|bool  The file contents on success. False on failure.
	 */
	private function save( $url, $path ) {
		$contents = $this->get_remote_contents( $url );

		if ( ! $contents ) {
			// Error, we couldn't fetch the file contents.
			return false;
		}

		return $this->update_file_contents( $path, $contents );
	}

	/**
	 * Add new contents to a file. If the file doesn't exist, it is created.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $file_path     Path to the file to update.
	 * @param  string $file_contents New contents.
	 * @return string|bool           The file contents on success. False on failure.
	 */
	private function update_file_contents( $file_path, $file_contents ) {
		if ( ! \rocket_direct_filesystem()->exists( $this->busting_path ) ) {
			\rocket_mkdir_p( $this->busting_path );
		}

		if ( ! \rocket_put_content( $file_path, $file_contents ) ) {
			Logger::error(
				'Contents could not be written into file.',
				[
					'fb pixel',
					'path' => $file_path,
				]
			);
			return false;
		}

		/**
		 * Triggered once a file contents have been updated.
		 *
		 * @since  3.2
		 * @author Grégory Viguier
		 *
		 * @param  string $file_path     Path to the file to update.
		 * @param  string $file_contents The file contents.
		 */
		do_action( 'rocket_after_facebook_pixel_file_updated', $file_path, $file_contents );

		return $file_contents;
	}

	/** ----------------------------------------------------------------------------------------- */
	/** PUBLIC BULK ACTIONS ON LOCAL FILES ====================================================== */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Look for existing local files and update their contents if there's a new version available.
	 * Actually, if a more recent version exists on the FB side, it will delete all local files and hit the home page to recreate them.
	 *
	 * @since  3.2
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @return bool True on success. False on failure.
	 */
	public function refresh_all() {
		// Get all local main files.
		$main_files = $this->get_all_main_files();

		if ( ! $main_files ) {
			// No files (or there's an error).
			return false !== $main_files;
		}

		$updated = false;

		foreach ( $main_files as $local_main_file ) {
			$remote_file_contents = $this->get_remote_contents( $this->get_main_file_url( $local_main_file['locale'] ) );

			if ( ! $remote_file_contents ) {
				continue;
			}

			$variables = $this->get_variables( $remote_file_contents );

			if ( ! $variables ) {
				unset( $remote_file_contents, $variables );
				continue;
			}

			if ( version_compare( $local_main_file['version'], $variables['version'] ) >= 0 ) {
				unset( $remote_file_contents, $variables );
				continue;
			}

			unset( $remote_file_contents );
			$updated = true;
			break;
		}

		if ( ! $updated ) {
			return true;
		}

		// Delete all local files.
		$deleted = $this->delete_all();

		// Purge all cache files (the URL of the new files changed).
		\rocket_clean_domain();

		// Preload the home page to recreate the files.
		$home_url = user_trailingslashit( home_url(), 'home' );

		wp_remote_get(
			$home_url,
			[
				'user-agent' => 'WP Rocket/Homepage Preload',
				'sslverify'  => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
			]
		);

		/**
		 * Triggered once the local files have been refreshed.
		 *
		 * @since  3.2
		 * @author Grégory Viguier
		 *
		 * @param string $version The new version.
		 */
		do_action( 'rocket_after_facebook_pixel_files_refreshed', $variables['version'] );

		return true;
	}

	/**
	 * Delete all Facebook Pixel busting files.
	 *
	 * @since  3.2
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @return bool True on success. False on failure.
	 */
	public function delete_all() {
		$filesystem = \rocket_direct_filesystem();
		$files      = $this->get_all_files();

		if ( ! $files ) {
			// No files (or there's an error).
			return false !== $files;
		}

		$error_paths = [];

		foreach ( $files as $file_name ) {
			if ( ! $filesystem->delete( $this->busting_path . $file_name, false, 'f' ) ) {
				$error_paths[] = $this->busting_path . $file_name;
			}
		}

		if ( $error_paths ) {
			Logger::error(
				'Local file(s) could not be deleted.',
				[
					'fb pixel',
					'paths' => $error_paths,
				]
			);
		}

		/**
		 * Triggered once all local files have been deleted (or not).
		 *
		 * @since  3.2
		 * @author Grégory Viguier
		 *
		 * @param array $files       An array of file names.
		 * @param array $error_paths Paths to the files that couldn't be deleted. An empty array if everything is fine.
		 */
		do_action( 'rocket_after_facebook_pixel_files_deleted', $files, $error_paths );

		return ! $error_paths;
	}

	/** ----------------------------------------------------------------------------------------- */
	/** SCAN FOR LOCAL FILES ==================================================================== */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Get all cached files in the directory.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @return array|bool A list of file names. False on failure.
	 */
	private function get_all_files() {
		$filesystem = \rocket_direct_filesystem();
		$dir_path   = rtrim( $this->busting_path, '\\/' );

		if ( ! $filesystem->exists( $dir_path ) ) {
			return [];
		}

		if ( ! $filesystem->is_writable( $dir_path ) ) {
			Logger::error(
				'Directory is not writable.',
				[
					'fb pixel',
					'path' => $dir_path,
				]
			);
			return false;
		}

		$dir = $filesystem->dirlist( $dir_path );

		if ( false === $dir ) {
			Logger::error(
				'Could not get the directory contents.',
				[
					'fb pixel',
					'path' => $dir_path,
				]
			);
			return false;
		}

		if ( ! $dir ) {
			return [];
		}

		$list = [];

		foreach ( $dir as $entry ) {
			if ( 'f' !== $entry['type'] ) {
				continue;
			}
			if ( preg_match( '@^fbpix-(?:config|events|plugin)-.+\.js$@', $entry['name'], $matches ) ) {
				$list[ $entry['name'] ] = $entry['name'];
			}
		}

		return $list;
	}

	/**
	 * Get all main files in the directory.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @return array|bool {
	 *     An array of file names (array keys) with following data as values. False on failure.
	 *
	 *     @type string $locale  The locale, like "en_US".
	 *     @type string $version The file version.
	 * }
	 */
	private function get_all_main_files() {
		$filesystem = \rocket_direct_filesystem();
		$dir_path   = rtrim( $this->busting_path, '\\/' );

		if ( ! $filesystem->exists( $dir_path ) ) {
			return [];
		}

		if ( ! $filesystem->is_writable( $dir_path ) ) {
			Logger::error(
				'Directory is not writable.',
				[
					'fb pixel',
					'path' => $dir_path,
				]
			);
			return false;
		}

		$dir = $filesystem->dirlist( $dir_path );

		if ( false === $dir ) {
			Logger::error(
				'could not get the directory contents.',
				[
					'fb pixel',
					'path' => $dir_path,
				]
			);
			return false;
		}

		if ( ! $dir ) {
			return [];
		}

		$list    = [];
		$pattern = $this->escape_file_name( $this->main_file_name );
		$pattern = sprintf( $pattern, self::LOCALE_CAPTURE . '-' . self::VERSION_CAPTURE );

		foreach ( $dir as $entry ) {
			if ( 'f' !== $entry['type'] ) {
				continue;
			}
			if ( preg_match( '@^' . $pattern . '$@', $entry['name'], $matches ) ) {
				unset( $matches[0] );
				$list[ $entry['name'] ] = $matches;
			}
		}

		return $list;
	}

	/**
	 * Get the most recent "version" of the main file cached locally.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @return string|bool The version on success. False on failure.
	 */
	private function get_most_recent_local_version() {
		$main_files = $this->get_all_main_files();

		if ( ! $main_files ) {
			return false;
		}

		$version = false;

		foreach ( $main_files as $file_name => $data ) {
			if ( ! $version || version_compare( $data['version'], $version ) > 0 ) {
				$version = $data['version'];
			}
		}

		return $version;
	}

	/**
	 * Get the oldest "version" of the main file cached locally.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @return string|bool The version on success. False on failure.
	 */
	private function get_oldest_local_version() {
		$main_files = $this->get_all_main_files();

		if ( ! $main_files ) {
			return false;
		}

		$version = false;

		foreach ( $main_files as $file_name => $data ) {
			if ( ! $version || version_compare( $data['version'], $version ) < 0 ) {
				$version = $data['version'];
			}
		}

		return $version;
	}

	/** ----------------------------------------------------------------------------------------- */
	/** REMOTE MAIN FILE ======================================================================== */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Get the remote Facebook Pixel URL.
	 *
	 * @since  3.2
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  string $locale A locale string, like 'en_US'.
	 * @return string
	 */
	public function get_main_file_url( $locale ) {
		return sprintf( $this->main_file_url, $locale );
	}

	/**
	 * Extract the locale from a URL to bust.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $url Any string containing the URL to bust.
	 * @return string|bool The locale on success. False on failure.
	 */
	private function get_locale_from_url( $url ) {
		$pattern = '@//connect\.facebook\.net/' . self::LOCALE_CAPTURE . '/fbevents\.js@i';

		if ( ! preg_match( $pattern, $url, $matches ) ) {
			return false;
		}

		return $matches['locale'];
	}

	/** ----------------------------------------------------------------------------------------- */
	/** BUSTING FILE (aka: cached copy of the main file) ======================================== */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Get the local Facebook Pixel URL (the "main" file).
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $locale A locale string, like 'en_US'.
	 * @param  string $version The script version.
	 * @return string
	 */
	private function get_busting_file_url( $locale, $version ) {
		$filename = $this->get_busting_file_name( $locale, $version );

		// This filter is documented in inc/functions/minify.php.
		return apply_filters( 'rocket_js_url', $this->busting_url . $filename );
	}

	/**
	 * Get the local Facebook Pixel file name.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $locale A locale string, like 'en_US'.
	 * @param  string $version The script version.
	 * @return string
	 */
	private function get_busting_file_name( $locale, $version ) {
		return sprintf( $this->main_file_name, $locale . '-' . $version );
	}

	/**
	 * Get the local Facebook Pixel file path.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $locale  A locale string, like 'en_US'.
	 * @param  string $version The script version.
	 * @return string
	 */
	private function get_busting_file_path( $locale, $version ) {
		return $this->busting_path . $this->get_busting_file_name( $locale, $version );
	}

	/** ----------------------------------------------------------------------------------------- */
	/** CONFIG FILE ============================================================================= */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Get the path to the local "config" file. If the file doesn't exist, it is created by fetching its contents remotely, then saved locally.
	 *
	 * @since  3.2
	 * @access private
	 * @see    $this->get_variables()
	 * @author Grégory Viguier
	 *
	 * @param  array $variables {
	 *     An array of variable values.
	 *
	 *     @type int    $app_id  The app ID.
	 *     @type string $version The file version.
	 * }
	 * @return string|bool The file path on success. False on failure.
	 */
	private function get_config_file_path( $variables ) {
		$config_file_url  = sprintf( $this->config_file_url, $variables['app_id'], $variables['version'] );
		$config_file_name = sprintf( $this->config_file_name, $variables['app_id'] . '-' . $variables['version'] );
		$config_file_path = $this->busting_path . $config_file_name;

		if ( ! $this->maybe_save( $config_file_url, $config_file_path ) ) {
			return false;
		}

		return $config_file_path;
	}

	/** ----------------------------------------------------------------------------------------- */
	/** PLUGIN FILES ============================================================================ */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Get the paths to all local "plugin" files. If the files don't exist, they are created by fetching their contents remotely, then saved locally.
	 *
	 * @since  3.2
	 * @access private
	 * @see    $this->get_variables()
	 * @author Grégory Viguier
	 *
	 * @param  array $variables {
	 *     An array of variable values.
	 *
	 *     @type string $app_id  The app ID.
	 *     @type string $version The file version.
	 * }
	 * @return array|bool An array of file paths on success. False on failure.
	 */
	private function get_plugin_file_paths( $variables ) {
		$paths        = [];
		$plugin_names = [
			'identity',
			'microdata',
			'inferredEvents',
			'dwell',
			'sessions',
			'timespent',
			'ga2fbq',
		];

		foreach ( $plugin_names as $plugin_name ) {
			$plugin_file_url  = sprintf( $this->plugins_file_url, $plugin_name, $variables['version'] );
			$plugin_file_name = sprintf( $this->plugins_file_name, $plugin_name . '-' . $variables['version'] );
			$plugin_file_path = $this->busting_path . $plugin_file_name;

			if ( ! $this->maybe_save( $plugin_file_url, $plugin_file_path ) ) {
				return false;
			}

			$paths[] = $plugin_file_path;
		}

		return $paths;
	}

	/** ----------------------------------------------------------------------------------------- */
	/** TOOLS =================================================================================== */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Get a file contents. If the file doesn't exist or is not writtable, new contents are fetched remotely.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $file_path Path to the file.
	 * @param  string $file_url  URL to the remote file.
	 * @return string|bool       The contents on success, false on failure.
	 */
	private function get_file_contents( $file_path, $file_url = false ) {
		$filesystem = \rocket_direct_filesystem();

		if ( $filesystem->is_writable( $file_path ) ) {
			// If a previous version is present, return its contents.
			$contents = $filesystem->get_contents( $file_path );

			if ( $contents ) {
				return $contents;
			}

			// In case the file is empty or we could not get its contents, try to get a fresh copy from remote location.
		}

		if ( ! $file_url ) {
			return false;
		}

		return $this->get_remote_contents( $file_url );
	}

	/**
	 * Get the contents of a URL.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $url The URL to request.
	 * @return string|bool The contents on success. False on failure.
	 */
	private function get_remote_contents( $url ) {
		try {
			$response = wp_remote_get( $url );
		} catch ( \Exception $e ) {
			Logger::error(
				'Remote file could not be fetched.',
				[
					'fb pixel',
					'url'      => $url,
					'response' => $e->getMessage(),
				]
			);
			return false;
		}

		if ( is_wp_error( $response ) ) {
			Logger::error(
				'Remote file could not be fetched.',
				[
					'fb pixel',
					'url'      => $url,
					'response' => $response->get_error_message(),
				]
			);
			return false;
		}

		$contents = wp_remote_retrieve_body( $response );

		if ( ! $contents ) {
			Logger::error(
				'Remote file could not be fetched.',
				[
					'fb pixel',
					'url'      => $url,
					'response' => $response,
				]
			);
			return false;
		}

		return $contents;
	}

	/**
	 * Escape a file name, to be used in a regex pattern (delimiter is `/`).
	 * `%s` conversion specifications are protected.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $file_name The file name.
	 * @return string
	 */
	private function escape_file_name( $file_name ) {
		$file_name = explode( '%s', $file_name );
		$file_name = array_map( 'preg_quote', $file_name );

		return implode( '%s', $file_name );
	}
}
