<?php
/**
 * Utility class for file operations.
 *
 * @package Ealab\Deployer
 *
 * @dirty Rename methods, reorder methods, reformat.
 */

namespace Ealab\Deployer\Utility;

use Dakwamine\Component\ComponentBasedObject;
use Dakwamine\Component\Exception\UnmetDependencyException;
use Dakwamine\Component\RootDependencyDefinition;
use Ealab\Deployer\Exception\WpDeployException;
use Ealab\Deployer\File\FileBase;
use Ealab\Deployer\File\KeyValueFile;
use Ealab\Deployer\Tracking\TrackingManager;
use Ealab\Deployer\Utility\BaseDirectoryDefiner\BaseDirectoryDefinerBase;
use Ealab\Deployer\Utility\BaseDirectoryDefiner\TempBaseDirectoryDefiner;
use WP_Filesystem_Base;

/**
 * Utility class for file operations.
 */
class FileManager extends ComponentBasedObject {
	/**
	 * The tracking manager.
	 *
	 * @var TrackingManager|null
	 */
	private ?TrackingManager $tracking_manager = null;

	/**
	 * Clears the items from a bundle.
	 *
	 * @param string     $bundle_id The bundle identifier.
	 * @param array|null $full_item_uuids_to_clear A specific list of item UUIDs to clear. Leave unset to clear all.
	 *
	 * @return bool If false, some or all items could not be deleted.
	 */
	public function clearDeploymentItemsFromBundle( string $bundle_id, ?array $full_item_uuids_to_clear = null ): bool {
		/** @var WP_Filesystem_Base $wp_filesystem */
		global $wp_filesystem;

		$directory = $this->getExportedDataDirectory( array( 'subdirectory' => $bundle_id ) ) . 'items/';

		if ( ! $this->directoryExists( $directory ) ) {
			return true;
		}

		if ( ! empty( $full_item_uuids_to_clear ) ) {
			// Delete only the specified UUIDs.
			$had_no_errors = true;

			foreach ( $full_item_uuids_to_clear as $uuid_to_clear ) {
				$had_no_error  = $wp_filesystem->delete( $directory . $uuid_to_clear . '.json' );
				$had_no_errors = ( ! $had_no_errors ) || $had_no_error;
			}

			return $had_no_errors;
		}

		return $wp_filesystem->rmdir( $directory, true );
	}

	/**
	 * Deletes the exported data directory.
	 *
	 * @return bool If false, some or all items could not be deleted.
	 */
	public function clearExportedData(): bool {
		/** @var WP_Filesystem_Base $wp_filesystem */
		global $wp_filesystem;

		$directory = $this->getExportedDataDirectory();

		return $wp_filesystem->rmdir( $directory, true );
	}

	/**
	 * Checks if a directory exists and return canonicalized absolute pathname.
	 *
	 * @param string $directory The path being checked.
	 *
	 * @return string|false The canonicalized absolute pathname on success. False otherwise.
	 */
	private function directoryExists( string $directory ) {
		// Get canonicalized absolute pathname.
		$path = realpath( $directory );

		// If it exists, check if it's a directory.
		return ( false !== $path && is_dir( $path ) ) ? $path : false;
	}

	/**
	 * Downloads a new bundle from PROD.
	 *
	 * @param string|null $since Date that can be translated by PHP. Can be left empty to retrieve most content.
	 * @param string|null $bundle_label Label.
	 *
	 * @return string The bundle ID / subdirectory.
	 *
	 * @throws WpDeployException Various failures during the process.
	 */
	public function downloadBundleFromProd( ?string $since = null, ?string $bundle_label = null ): string {
		if ( $this->tracking_manager->isProductionEnvironment() ) {
			// We are on the production environment, so we cannot download the data from production.
			throw new WpDeployException( _dgettext( 'deployer-core', 'Tried to download a bundle from prod, but we are already on prod.' ) );
		}

		$api_path = add_query_arg(
			array(
				'bundle_label' => $bundle_label ?? '',
				// When the given since date is unset, we set a "fake" date.
				// Items with an older modified date will not synchronize.
				'since'        => $since ?? Date::toDateTime( '1980-01-01', new \DateTimeZone( 'UTC' ) )->format( Date::ISO ),
			),
			'/wp-json/deploy-sync-content/v1/deployment-bundle/download'
		);

		// Use guzzle.
		$prod_response = Api::callApiProd( 'POST', $api_path, null, false, array( 'use_guzzle' => true ) );

		// Get the file name sent by prod; it contains the bundle ID, so it's important.
		// Extract the filename from the Content-Disposition header field.
		$content_disposition_header_values = $prod_response->getHeader( 'Content-Disposition' );

		if ( empty( $content_disposition_header_values ) ) {
			throw new WpDeployException( _dgettext( 'deployer-core', 'Unexpected return data.' ) );
		}

		// Use the first value; we expect only one value.
		$content_disposition_header = reset( $content_disposition_header_values );

		// Find where the filename is set.
		$filename_strpos = strpos( $content_disposition_header, 'filename=' );

		if ( false === $filename_strpos ) {
			throw new WpDeployException( _dgettext( 'deployer-core', 'Unexpected return data.' ) );
		}

		$zip_file_name = substr( $content_disposition_header, $filename_strpos + 9 );

		// Trim the quotes if any.
		$zip_file_name = trim( $zip_file_name, '"' );

		if ( empty( $zip_file_name ) ) {
			throw new WpDeployException( _dgettext( 'deployer-core', 'Unexpected return data.' ) );
		}

		// Create a stream that ZipArchive can load.
		$temp_base_dir = ( new TempBaseDirectoryDefiner() )->getBaseDirectory();

		$temp_file_path = tempnam( $temp_base_dir, 'download_bundle_from_prod_' );

		if ( empty( $temp_file_path ) ) {
			throw new WpDeployException( _dgettext( 'deployer-core', 'Could not save the downloaded archive in the temporary directory.' ) );
		}

		try {
			// Get the content of the zip file.
			$prod_bundle_content = $prod_response->getBody()->getContents();

			// Write the content in the temporary file.
			if ( false === file_put_contents( $temp_file_path, $prod_bundle_content ) ) {
				throw new WpDeployException( _dgettext( 'deployer-core', 'Could not save the downloaded archive in the temporary directory.' ) );
			}

			// Extract to the final directory.
			$target_dir = $this->getExportedDataDirectory();

			$result = $this->unzipFiles( $zip_file_name, $temp_file_path, $target_dir );

			if ( empty( $result ) ) {
				throw new WpDeployException( _dgettext( 'deployer-core', 'Could not extract the downloaded data to the final directory.' ) );
			}

			return static::getBundleIdFromZipFileName( $zip_file_name );
		} finally {
			// Unlink the temporary created file.
			unlink( $temp_file_path );
		}
	}

	/**
	 * Ensures the directory exists.
	 *
	 * @param string $directory The directory to check.
	 *
	 * @return bool True if the directory exists, false otherwise.
	 */
	public function ensureDirectoryExists( string $directory ): bool {
		/** @var WP_Filesystem_Base $wp_filesystem */
		global $wp_filesystem;

		if ( $wp_filesystem->is_dir( $directory ) ) {
			return true;
		}

		if ( $wp_filesystem->is_file( $directory ) ) {
			// This should never happen, but just in case the file system was corrupted, delete the file.
			$wp_filesystem->delete( $directory );
		}

		// Attempt to create the directory.
		// TODO: this does not use WP filesystem. Make sure it does not cause issues.
		return wp_mkdir_p( $directory );
	}

	/**
	 * Gets all the trackable items file names.
	 *
	 * @param string $subdirectory The subdirectory to append to the exported-data directory path,
	 *   in which the items are located.
	 *
	 * @return array<string,string> The trackable items file names, keyed by their full UUID.
	 *
	 * @throws WpDeployException If the given bundle subdirectory is invalid.
	 */
	public function getAllAvailableUuidsForImport( string $subdirectory ): array {
		/** @var WP_Filesystem_Base $wp_filesystem */
		global $wp_filesystem;

		$exported_directory = $this->getExportedDataDirectory();

		if ( ! is_dir( $exported_directory ) ) {
			$wp_filesystem->mkdir( $exported_directory );
		}

		$available_uuids = array();

		try {
			$directory_iterator = new \DirectoryIterator( $this->getExportedDataDirectory( array( 'subdirectory' => $subdirectory ) ) );
		} catch ( \Exception $e ) {
			throw new WpDeployException( 'The given bundle subdirectory is invalid.' );
		}

		/** @var \SplFileInfo $item */
		foreach ( $directory_iterator as $item ) {
			if ( ! $item->isDir() || $item->getFilename() !== 'items' ) {
				continue;
			}

			$package_items_directory_iterator = new \DirectoryIterator( $item->getPathname() );

			/** @var \SplFileInfo $file */
			foreach ( $package_items_directory_iterator as $file ) {
				if ( $file->isDir() ) {
					continue;
				}

				if ( ! $file->isFile() ) {
					continue;
				}

				if ( $file->getExtension() !== 'json' ) {
					continue;
				}

				if ( ! $file->isReadable() ) {
					continue;
				}

				$file_name = $file->getFilename();

				if ( count( explode( '.', $file_name ) ) !== 3 ) {
					continue;
				}

				// Remove the extension to get the item full UUID.
				$item_full_uuid = substr( $file_name, 0, -5 );

				$available_uuids[ $item_full_uuid ] = $item_full_uuid;
			}

			break;
		}

		return $available_uuids;
	}

	/**
	 * Gets the base directory path.
	 *
	 * @return string The base directory, with trailing slash.
	 */
	public function getBaseDirectory(): string {
		try {
			// Check if there is a base directory definer component.
			$base_directory_definer = $this->getComponentByClassName( BaseDirectoryDefinerBase::class );
		} catch ( UnmetDependencyException $e ) {
			// This should never happen because we are not trying to instantiate the component.
			return deploy_sync_content__get_base_dir();
		}

		if ( empty( $base_directory_definer ) ) {
			// No definer set; use the default behaviour.
			return deploy_sync_content__get_base_dir();
		}

		return $base_directory_definer->getBaseDirectory();
	}

	/**
	 * The class name of the current definer, if any.
	 *
	 * @return class-string|null Class name, or null if no definer set.
	 */
	public function getBaseDirectoryDefinerClass(): ?string {
		try {
			$current_definer = $this->getComponentByClassName( BaseDirectoryDefinerBase::class );
		} catch ( UnmetDependencyException $e ) {
			// Should never happen because no instantiation tried.
			return null;
		}

		return $current_definer ? get_class( $current_definer ) : null;
	}

	/**
	 * Gets the bundle ID from the given zip file name.
	 *
	 * @param string $zip_file_name Zip file name of the Bundle ID / subdirectory. It also supports full paths.
	 *
	 * @return string The bundle ID, or null if it could not be retrieved.
	 */
	public static function getBundleIdFromZipFileName( string $zip_file_name ): ?string {
		$zip_path_array = explode( '/', $zip_file_name );

		if ( empty( $zip_path_array ) ) {
			return null;
		}

		// This is the complete full name: "deploy-sync-content--export-{bundle_id}.zip".
		$prefixed_file_name = end( $zip_path_array );

		$prefix = 'deploy-sync-content--export-';

		$extension = '.zip';

		if ( substr( $prefixed_file_name, 0, strlen( $prefix ) ) !== $prefix ) {
			// Not prefixed by the right prefix.
			return null;
		}

		if ( substr( $prefixed_file_name, -strlen( $extension ) ) !== $extension ) {
			// Not suffixed by the right extension.
			return null;
		}

		$bundle_id = substr( $prefixed_file_name, strlen( $prefix ), -strlen( $extension ) );

		// Force null when the bundle_id is an empty string or invalid.
		return ! empty( $bundle_id ) ? $bundle_id : null;
	}

	/**
	 * Gets the files included into the bundle transfers (downloads and send to prod).
	 *
	 * @param string $subdirectory The subdirectory to prepend to the patterns.
	 *
	 * @return string[] The file path regex patterns.
	 */
	private function getBundleTransferInclusionsFilePatterns( string $subdirectory ): array {
		$name_without_slash_nor_unsupported_chars = '(?:(?![\/^*?"<>|:]+).)+';

		$regexified_subdirectory = preg_quote( untrailingslashit( $subdirectory ), '/' );

		// Check the most often checked patterns first for performance reasons.
		return array(
			'/^' . $regexified_subdirectory . preg_quote( DIRECTORY_SEPARATOR . 'items' . DIRECTORY_SEPARATOR, '/' ) . $name_without_slash_nor_unsupported_chars . '\.json$/',
			'/^' . $regexified_subdirectory . preg_quote( DIRECTORY_SEPARATOR, '/' ) . 'content\.json$/',
		);
	}

	/**
	 * {@inheritDoc}
	 */
	public function getDependencyDefinitions(): array {
		$definitions   = parent::getDependencyDefinitions();
		$definitions[] = new RootDependencyDefinition( TrackingManager::class, $this->tracking_manager );

		return $definitions;
	}

	/**
	 * Gets the local metadata of a deployment bundle.
	 *
	 * @param string $bundle_id The bundle ID.
	 * @param string $key The key of the metadata.
	 * @param mixed  $fallback The fallback value if the metadata is not found.
	 *
	 * @return mixed The metadata value.
	 */
	public function getDeploymentBundleLocalMetadata( string $bundle_id, string $key, $fallback = null ) {
		try {
			$local_state = $this->loadDeploymentBundleMetadata( $bundle_id, 'local-state.json' ) ?? new KeyValueFile();

			return $local_state->getValue( $key, $fallback );
		} catch ( WpDeployException $e ) {
			// Not an error we need to handle.
			return null;
		}
	}

	/**
	 * Gets the metadata of a deployment bundle.
	 *
	 * @param string $bundle_id The bundle ID.
	 * @param string $key The key of the metadata.
	 * @param mixed  $fallback The fallback value if the metadata is not found.
	 *
	 * @return mixed The metadata value.
	 */
	public function getDeploymentBundleMetadata( string $bundle_id, string $key, $fallback = null ) {
		try {
			$local_state = $this->loadDeploymentBundleMetadata( $bundle_id, 'content.json' ) ?? new KeyValueFile();

			return $local_state->getValue( $key, $fallback );
		} catch ( WpDeployException $e ) {
			// Not an error we need to handle.
			return null;
		}
	}

	/**
	 * Gets the subdirectories in which the exported data are located.
	 *
	 * @param string|null $list_type One of the following: "import", "export", null.
	 *   - "import": List the bundles available for import.
	 *   - "export": List the bundles available for export.
	 *   - null (default): List all the bundles.
	 *
	 * @return array<string,array> The subdirectories, keyed by their name.
	 *   Each value is an array keyed with the following keys:
	 *   - datetime: The DateTime object inferred from the subdirectory name.
	 *   - subdirectory: The subdirectory name.
	 */
	public function getExistingExportedDataSubdirectories( string $list_type = null ): array {
		/** @var WP_Filesystem_Base $wp_filesystem */
		global $wp_filesystem;

		$exported_directory = $this->getExportedDataDirectory();

		if ( ! is_dir( $exported_directory ) ) {
			$wp_filesystem->mkdir( $exported_directory );
		}

		$current_environment_url = $this->tracking_manager->getCurrentEnvironmentUrl();

		$available_subdirectories = array();

		/** @var \DirectoryIterator $file */
		foreach ( new \DirectoryIterator( $exported_directory ) as $file ) {
			if ( $file->isDot() ) {
				continue;
			}

			if ( ! $file->isDir() ) {
				continue;
			}

			$file_name = $file->getFilename();

			// The file name is the date.
			$datetime = Date::toDateTimeWithFormat( Date::ISO_COMPACT, $file_name );

			if ( empty( $datetime ) ) {
				continue;
			}

			switch ( $list_type ) {
				case 'export':
				case 'import':
				case null:
					// Show all the bundles.
					break;

				default:
					// Unknown list type, stop the process.
					return array();
			}

			$available_subdirectories[ $file_name ] = array(
				'datetime'     => $datetime,
				'subdirectory' => $file_name,
			);
		}

		// Sort by date, most recent first.
		krsort( $available_subdirectories );

		return $available_subdirectories;
	}

	/**
	 * Gets the exported data directory.
	 *
	 * @param array<string,mixed> $options Options for the directory path. Keys are the following:
	 *   - with_trailing_slash (bool): Whether to append a trailing slash to the directory path. Default: true.
	 *   - subdirectory (string): The sub-directory to append to the directory path. Default: ''.
	 *
	 * @return string The exported data directory.
	 */
	private function getExportedDataDirectory( array $options = array() ): string {
		$with_trailing_slash = $options['with_trailing_slash'] ?? true;
		$subdirectory        = $options['subdirectory'] ?? '';

		$path = $this->getBaseDirectory() . 'exported-data/' . $subdirectory;

		$normalized_path = untrailingslashit( $this->normalizeOsDirectorySeparators( $path ) );

		// Dev note: do not use the trailingslashit function because it does not use the DIRECTORY_SEPARATOR constant.
		return $with_trailing_slash
			? $normalized_path . DIRECTORY_SEPARATOR
			: $normalized_path;
	}

	/**
	 * Gets the full filename for the item with the provided full UUID.
	 *
	 * @param string $subdirectory The subdirectory to append to the directory path.
	 * @param string $item_full_uuid The item full UUID.
	 *
	 * @return string The item full filename.
	 */
	private function getTrackableItemFilename( string $subdirectory, string $item_full_uuid ): string {
		$filename = $this->getExportedDataDirectory( array( 'subdirectory' => $subdirectory ) ) . 'items' . DIRECTORY_SEPARATOR . $item_full_uuid . '.json';

		return $filename;
	}

	/**
	 * Gets the file name of the zip file for the given bundle ID / subdirectory.
	 *
	 * @param string $subdirectory Bundle ID / subdirectory.
	 *
	 * @return string The file name, file extension included.
	 */
	public static function getZipFileNameForExport( string $subdirectory ): string {
		return 'deploy-sync-content--export-' . $subdirectory . '.zip';
	}

	/**
	 * Handles a zip upload.
	 *
	 * @return bool|string|void The informative result of the zip upload process.
	 */
	public function handleZipUpload() {
		if ( ! isset( $_POST['zip_upload_nonce'] ) ) {
			return;
		}

		if ( ! wp_verify_nonce( $_POST['zip_upload_nonce'], 'zip_upload_nonce' ) ) {
			return;
		}

		$result = 'No file selected';

		if ( isset( $_FILES['zip_file'] ) && 0 === ( $_FILES['zip_file']['error'] ?? null ) ) {
			$zip_file      = $_FILES['zip_file']['tmp_name'];
			$zip_file_name = $_FILES['zip_file']['name'];
			$target_dir    = $this->getBaseDirectory() . 'exported-data/';

			$result = $this->unzipFiles( $zip_file_name, $zip_file, $target_dir );
		}

		return $result;
	}

	/**
	 * Loads a file.
	 *
	 * @param string $path The path to the file.
	 *
	 * @return string|null The file content, or false if the file could not be loaded.
	 */
	private function load( string $path ): ?string {
		/** @var WP_Filesystem_Base $wp_filesystem */
		global $wp_filesystem;

		if ( ! $wp_filesystem->exists( $path ) || ! $wp_filesystem->is_file( $path ) ) {
			return null;
		}

		$content = $wp_filesystem->get_contents( $path );

		return false === $content ? null : $content;
	}

	/**
	 * Loads the metadata for the provided deployment bundle.
	 *
	 * @param string $subdirectory The subdirectory in which the deployment bundle is located.
	 * @param string $file_remaining_path The remaining path to the metadata file.
	 *
	 * @return KeyValueFile|null The metadata file, or null if the file could not be loaded.
	 *
	 * @throws WpDeployException If the metadata file could not be loaded due to a serious error.
	 */
	public function loadDeploymentBundleMetadata( string $subdirectory, string $file_remaining_path ): ?KeyValueFile {
		$full_path = $this->getExportedDataDirectory( array( 'subdirectory' => $subdirectory ) ) . ltrim( $file_remaining_path, '/\\' );

		$directory = dirname( $full_path );

		if ( ! $this->directoryExists( $directory ) ) {
			return null;
		}

		$metadata = $this->load( $full_path );

		if ( null === $metadata ) {
			return null;
		}

		$metadata_file = new KeyValueFile();
		$metadata_file->setRawFileContent( $metadata );

		return $metadata_file;
	}

	/**
	 * Loads the data for the provided deployment items UUIDs.
	 *
	 * @param string        $subdirectory The subdirectory to append to the exported-data directory path.
	 * @param array<string> $item_full_uuids The deployment items UUIDs.
	 *
	 * @return array<string,array>|null The deployment items data, or null if some items could not be loaded.
	 *   Indexed by their full UUID.
	 *
	 * @throws WpDeployException Error while loading the deployment items data.
	 */
	public function loadDeploymentItemsData( string $subdirectory, array $item_full_uuids ): ?array {
		$file_names = array();
		$items      = array();

		foreach ( $item_full_uuids as $item_full_uuid ) {
			$file_names[ $item_full_uuid ] = $this->getTrackableItemFilename( $subdirectory, $item_full_uuid );
		}

		foreach ( $file_names as $item_full_uuid => $file_name ) {
			$loaded = $this->load( $file_name );

			if ( null === $loaded ) {
				continue;
			}

			$decoded = json_decode( $loaded, true );

			if ( json_last_error() !== JSON_ERROR_NONE || null === $decoded || ! is_array( $decoded ) ) {
				// Could not decode the JSON.
				throw new WpDeployException( 'Could not decode the JSON for the item: ' . $item_full_uuid );
			}

			$items[ $item_full_uuid ] = $decoded;
		}

		return $items;
	}

	/**
	 * Normalizes directory separators for the current OS. Works with non-existing items.
	 *
	 * @param string $path The path to convert.
	 *
	 * @return string The path, with the proper directory separator.
	 */
	private function normalizeOsDirectorySeparators( string $path ): string {
		return str_replace( array( '/', '\\' ), DIRECTORY_SEPARATOR, $path );
	}

	/**
	 * {@inheritDoc}
	 */
	protected function onReady(): void {
		/** @var WP_Filesystem_Base $wp_filesystem */
		global $wp_filesystem;

		if ( ! $wp_filesystem ) {
			if ( ! function_exists( 'WP_Filesystem' ) ) {
				require_once ABSPATH . 'wp-admin/includes/file.php';
			}

			// Initialize the file system using the direct method.
			WP_Filesystem();
		}
	}

	/**
	 * Deletes the bundle directory.
	 *
	 * @param string $subdirectory The subdirectory in which the items are located.
	 *
	 * @return bool If false, some or all items could not be deleted.
	 */
	public function removeBundleDirectory( string $subdirectory ): bool {
		/** @var WP_Filesystem_Base $wp_filesystem */
		global $wp_filesystem;

		$exported_directory = $this->getExportedDataDirectory();

		if ( ! is_dir( $exported_directory . '/' . $subdirectory ) ) {
			return false;
		}

		return $wp_filesystem->rmdir( $exported_directory . '/' . $subdirectory, true );
	}

	/**
	 * Saves the provided content to the provided path.
	 *
	 * @param string $path The path to save the content to.
	 * @param string $content The content to save.
	 *
	 * @return bool Whether the content was saved successfully.
	 */
	private function save( string $path, string $content ): bool {
		/** @var WP_Filesystem_Base $wp_filesystem */
		global $wp_filesystem;

		if ( $wp_filesystem->exists( $path ) ) {
			$result = $wp_filesystem->delete( $path );

			if ( ! $result ) {
				return false;
			}
		}

		// Write.
		$result = $wp_filesystem->put_contents( $path, $content );

		return $result;
	}

	/**
	 * Saves a metadata file for a deployment bundle.
	 *
	 * @param string   $subdirectory The bundle subdirectory in which the items are located.
	 * @param string   $file_remaining_path The remaining path of the file, e.g. "content.json", "local-data.json".
	 * @param FileBase $file The file content to save.
	 *
	 * @return bool If false, the file could not be saved.
	 */
	public function saveDeploymentBundleMetadata( string $subdirectory, string $file_remaining_path, FileBase $file ): bool {
		$full_path = $this->getExportedDataDirectory( array( 'subdirectory' => $subdirectory ) ) . ltrim( $file_remaining_path, '/\\' );

		$directory = dirname( $full_path );

		if ( ! $this->ensureDirectoryExists( $directory ) ) {
			return false;
		}

		return $this->save( $full_path, $file->getRawFileContent() );
	}

	/**
	 * Saves the provided trackable item.
	 *
	 * @param string $subdirectory The subdirectory to append to the exported-data directory path.
	 * @param string $item_full_uuid The trackable item full UUID.
	 * @param array  $item_content The trackable item content, as given by TrackingManager::buildExportData().
	 * @param array  $options Additional options. Keys:
	 *   - update_bundle_content_json: Whether to update the bundle content.json metadata file. Default: false.
	 *
	 * @return bool Whether the trackable item was saved successfully.
	 */
	public function saveTrackableItem( string $subdirectory, string $item_full_uuid, array $item_content, array $options = array() ): bool {
		$filename = $this->getTrackableItemFilename( $subdirectory, $item_full_uuid );

		$directory = dirname( $filename );

		if ( ! $this->ensureDirectoryExists( $directory ) ) {
			return false;
		}

		return $this->save( $filename, wp_json_encode( $item_content ) );
	}

	/**
	 * Function to handle the send to prod json data process
	 *
	 * @param string $subdirectory The deployment bundle subdirectory.
	 *
	 * @return bool True on success, false on failure.
	 */
	public function sendJsonToProd( string $subdirectory ): bool {
		if ( empty( $subdirectory ) ) {
			// Missing bundle ID.
			return false;
		}

		if ( $this->tracking_manager->isProductionEnvironment() ) {
			// We are on the production environment, so we cannot send the data to production.
			return false;
		}

		$directory_to_send = $this->getExportedDataDirectory( array( 'subdirectory' => $subdirectory ) );

		if ( ! is_dir( $directory_to_send ) ) {
			// The directory does not exist or is not readable.
			return false;
		}

		$request_body = array( 'subdirectory' => $subdirectory );

		// Add files data to the JSON data array.
		$files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory_to_send ) );

		foreach ( $this->getBundleTransferInclusionsFilePatterns( $directory_to_send ) as $pattern ) {
			$filtered_files = new \RegexIterator( $files, $pattern );

			/** @var \SplFileInfo $file */
			foreach ( $filtered_files as $file ) {
				if ( $file->isDir() ) {
					continue;
				}

				$file_relative_path = $file->getRealPath();
				$file_name          = str_replace( $directory_to_send, '', $file_relative_path );

				// Read the JSON file.
				$file_json = file_get_contents( $file_relative_path );

				if ( false === $file_json ) {
					// The file could not be read. Abort the whole process.
					return false;
				}

				// Decode the JSON file and push it to the data array.
				$request_body['data'][] = array(
					'filename'  => $file_name,
					'json_data' => json_decode( $file_json, true ),
				);
			}
		}

		try {
			return Api::callApiProd( 'POST', '/wp-json/deploy-sync-content/v1/sendtoprod', $request_body );
		} catch ( \Exception $e ) {
			// TODO: better error handling than an echo.
			//echo 'Caught exception: ', $e->getMessage(), "\n";
			return false;
		}
	}

	/**
	 * Function to handle the send to prod process
	 *
	 * @param string $subdirectory : Trackable UUID
	 *
	 * @return bool
	 *
	 * @deprecated Unused. Reuse after a cleanup pass or delete.
	 */
	public function sendZipToProd( string $subdirectory ): bool {
		/** @var WP_Filesystem_Base $wp_filesystem */
		global $wp_filesystem;

		$exported_directory = $this->getExportedDataDirectory();

		if ( ! is_dir( $exported_directory ) ) {
			$wp_filesystem->mkdir( $exported_directory );
		}

		// Define the zip filename
		$zip_file_name = 'deploy-sync-content--export-' . $subdirectory . '.zip';
		// Define the directory you want to zip
		$directory_to_zip = $this->getBaseDirectory() . 'exported-data/' . $subdirectory . '/';
		// Create a temporary zip file
		$zip_file = tempnam( $this->getBaseDirectory() . 'exported-data/', 'deploy_sync_content__zip' );
		$zip      = new \ZipArchive();

		if ( $zip->open( $zip_file, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) === true ) {
			// Create a new folder for the trackable ID
			$zip->addEmptyDir( $subdirectory );
			// Add files to the zip archive
			$files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory_to_zip ) );
			foreach ( $files as $file ) {
				if ( ! $file->isDir() ) {
					$file_relative_path = str_replace( $directory_to_zip, '', $file->getRealPath() );
					$zip->addFile( $file->getRealPath(), $subdirectory . '/' . $file_relative_path );
				}
			}
			// Close the zip archive
			$zip->close();

			$dsc_settings_data = Settings::getInstance()->getAll();
			$access_token             = ! empty( $dsc_settings_data['access_token'] ) ? $dsc_settings_data['access_token'] : '';
			$url_prod                 = ! empty( $dsc_settings_data['url_prod'] ) ? $dsc_settings_data['url_prod'] : '';
			$routeApi                 = rtrim( $url_prod, '/' ) . '/wp-json/deploy-sync-content/v1/sendtoprod';

			$postData = array(
				'zip_file' => new \CURLFile( $zip_file ),
			);

			try {
				$sendzip = Api::callApi( 'POST', $routeApi, $access_token, '', $postData, true );

			} catch ( \Exception $e ) {
				echo 'Caught exception: ', $e->getMessage(), "\n";
			}

			// Clean up the temporary zip file
			unlink( $zip_file );

			return $sendzip;
		}

	}

	/**
	 * Sets the base directory definer.
	 *
	 * @param class-string|null $classname Class name. If empty, will remove the definer for default behaviour.
	 *     Must inherit from BaseDirectoryDefinerBase.
	 *
	 * @throws WpDeployException Instantiation error of the definer.
	 */
	public function setBaseDirectoryDefinerByClass( ?string $classname = null ): void {
		if ( empty( $classname ) ) {
			$this->removeComponentsByClassName( BaseDirectoryDefinerBase::class );

			return;
		}

		$definers = $this->getComponentsByClassName( BaseDirectoryDefinerBase::class );

		if ( count( $definers ) !== 1 || ! ( reset( $definers ) instanceof $classname ) ) {
			$this->removeComponentsByClassName( BaseDirectoryDefinerBase::class );

			try {
				$this->addComponentByClassName( $classname );
			} catch ( UnmetDependencyException $e ) {
				throw new WpDeployException( 'Instantiation error of the definer.', 0, $e );
			}
		}
	}

	/**
	 * Function to handle the unzip process
	 *
	 * @param string $zip_file_name The zip file name.
	 * @param string $zip_path The zip path.
	 * @param string $base_target_dir The base target directory.
	 *
	 * @return bool True on success, false on failure.
	 */
	public function unzipFiles( string $zip_file_name, string $zip_path, string $base_target_dir ): bool {
		if ( empty( $zip_file_name ) || empty( $zip_path ) || empty( $base_target_dir ) ) {
			return false;
		}

		// Ensure the trailing slash is present.
		$base_target_dir = trailingslashit( $base_target_dir );

		if ( ! is_dir( $base_target_dir ) ) {
			// TODO: Handle the case where the directory cannot be created (throw exception or return false).
			$this->ensureDirectoryExists( $base_target_dir );
		}

		$bundle_directory_name = static::getBundleIdFromZipFileName( $zip_file_name );

		if ( $this->directoryExists( $base_target_dir . $bundle_directory_name ) ) {
			// Remove the final target directory.
			// TODO: Handle the case where files couldn't be unlinked (throw exception or return false).
			$this->removeBundleDirectory( $bundle_directory_name );
		}

		$zip = new \ZipArchive();

		if ( true !== $zip->open( $zip_path ) ) {
			// Error on opening the zip file.
			return false;
		}

		// Extracts to the base target directory.
		$unzip_result = $zip->extractTo( $base_target_dir );

		// Get the directory name where files were extracted.
		$bundle_directory_name = $zip->getNameIndex( 0 );

		$zip->close();

		if ( is_wp_error( $unzip_result ) ) {
			echo 'Error: ' . esc_html( $unzip_result );

			return false;
		}

		// Set the transfer date.
		$local_state = $this->loadDeploymentBundleMetadata( $bundle_directory_name, 'local-state.json' ) ?? new KeyValueFile();
		$local_state->setValue( 'transfer_date', Date::getCurrentDateTime()->format( Date::ISO_MILLISECONDS ) );
		$this->saveDeploymentBundleMetadata( $bundle_directory_name, 'local-state.json', $local_state );

		return $unzip_result;
	}

	/**
	 * Update plugin with Zip file.
	 *
	 * @param string $zip_url The Zip url of the new plugin version.
	 * @param string $zip_version The version of the latest Zip file.
	 *
	 * @throws WpDeployException Could not update.
	 *
	 * @deprecated Not used anymore (updates are handled by WP now). To recycle.
	 */
	public function updatePluginZipFile( string $zip_url, string $zip_version ): void {
		if ( filter_var( $zip_url, FILTER_VALIDATE_URL ) === false ) {
			throw new WpDeployException( _dgettext( 'deployer-core', 'Invalid zip file URL.' ) );
		}

		$dsc_settings_data = get_option( 'deploy_sync_content__settings_data', false );
		$licence_key              = ! empty( $dsc_settings_data ) && isset( $dsc_settings_data['licence_key'] ) ? $dsc_settings_data['licence_key'] : '';

		// Name of the directory where files are extracted.
		$extract_dir = WP_PLUGIN_DIR . '/';

		// Rename .zip file.
		$zip_file = $extract_dir . DEPLOY_SYNC_CONTENT__SLUG . $zip_version . '.zip';

		$zip_resource = fopen( $zip_file, "w" );

		$headr   = array();
		$headr[] = 'Content-type: application/json';
		$headr[] = 'Content-Length: 0';
		$headr[] = 'Accept: */*';
		$headr[] = 'Connection: keep-alive';
		$headr[] = 'X-EaLab-Api-Key: ' . $licence_key;

		// Get The Zip File From Server.
		$ch = curl_init();
		curl_setopt( $ch, CURLOPT_URL, $zip_url );
		curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		curl_setopt( $ch, CURLOPT_POST, 1 );
		curl_setopt( $ch, CURLOPT_HTTPHEADER, $headr );
		curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true );
		curl_setopt( $ch, CURLOPT_AUTOREFERER, true );
		curl_setopt( $ch, CURLOPT_BINARYTRANSFER, true );
		curl_setopt( $ch, CURLOPT_TIMEOUT, 0 );
		curl_setopt( $ch, CURLOPT_FILE, $zip_resource );

		$page = curl_exec( $ch );

		curl_close( $ch );

		if ( ! $page ) {
			throw new WpDeployException( 'Error: ' . curl_error( $ch ) );
		}

		/* Open the Zip file */
		$zip          = new \ZipArchive();
		$extract_path = $extract_dir . DEPLOY_SYNC_CONTENT__SLUG . '/';

		if ( true !== $zip->open( $zip_file ) ) {
			throw new WpDeployException( 'Error: Unable to open the Zip File.' );
		}

		/* Extract Zip File */
		$zip->extractTo( $extract_path );
		$zip->close();

		wp_delete_file( $zip_file );
	}

	/**
	 * Zip and Download an Export Data.
	 *
	 * @param string $subdirectory The subdirectory to append to the exported-data directory path.
	 *
	 * @throws WpDeployException Error on zip creation or download.
	 */
	public function zipAndDownloadExport( string $subdirectory ): void {
		$zip_file = $this->zipBundle( $subdirectory );

		$zip_file_name = static::getZipFileNameForExport( $subdirectory );

		// Set headers to force download.
		if ( ob_get_contents() ) {
			ob_end_clean();
		}

		header( 'Pragma: public' );
		header( 'Expires: 0' );
		header( 'Cache-Control: must-revalidate, post-check=0, pre-check=0' );
		header( 'Cache-Control: private', false );
		header( 'Content-type: application/zip' );
		header( 'Content-Disposition: attachment; filename=' . $zip_file_name . ';' );
		header( 'Content-length: ' . filesize( $zip_file ) );
		header( 'Content-Transfer-Encoding: binary' );

		// Send the zip file to the browser for download.
		readfile( $zip_file );

		// Clean up the temporary zip file.
		unlink( $zip_file );
	}

	/**
	 * Creates a zip archive for the given bundle. Don't forget to unlink it when done.
	 *
	 * @param string $subdirectory Subdirectory, aka bundle.
	 *
	 * @return string The zip full file path.
	 *
	 * @throws WpDeployException Various errors.
	 */
	public function zipBundle( string $subdirectory ): string {
		if ( empty( $subdirectory ) ) {
			throw new WpDeployException( _dgettext( 'deployer-core', 'Failed to create the zip file.' ) );
		}

		$directory_to_zip = $this->getExportedDataDirectory( array( 'subdirectory' => $subdirectory ) );

		if ( ! is_dir( $directory_to_zip ) ) {
			// The directory does not exist, is not readable, or is not a directory.
			throw new WpDeployException( _dgettext( 'deployer-core', 'Failed to create the zip file.' ) );
		}

		// Create a temporary zip file.
		$zip_file = tempnam( $this->getBaseDirectory() . 'exported-data/', 'deploy_sync_content__zip' );
		$zip      = new \ZipArchive();

		if ( true !== $zip->open( $zip_file, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
			throw new WpDeployException( _dgettext( 'deployer-core', 'Failed to create the zip file.' ) );
		}

		// Create a new folder for the deployment bundle.
		$zip->addEmptyDir( $subdirectory );

		// Add files to the zip archive.
		$files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory_to_zip ) );

		foreach ( $this->getBundleTransferInclusionsFilePatterns( $directory_to_zip ) as $pattern ) {
			$filtered_files = new \RegexIterator( $files, $pattern );

			/** @var \SplFileInfo $file */
			foreach ( $filtered_files as $file ) {
				if ( $file->isDir() ) {
					continue;
				}

				$file_relative_path = substr( $file->getRealPath(), strlen( $directory_to_zip ) );
				$zip->addFile( $file->getRealPath(), $subdirectory . DIRECTORY_SEPARATOR . $file_relative_path );
			}
		}

		// Close the zip archive.
		$zip->close();

		// Return the file name. Don't forget to unlink it when done.
		return $zip_file;
	}
}
