<?php
/**
 * Tracking manager.
 *
 * @package Ealab\Deployer
 */

namespace EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking;

use EaLab\WP_Deployer\vendor\Dakwamine\Component\ComponentBasedObject;
use EaLab\WP_Deployer\vendor\Dakwamine\Component\ComponentBucketType;
use EaLab\WP_Deployer\vendor\Dakwamine\Component\Exception\UnmetDependencyException;
use EaLab\WP_Deployer\vendor\Dakwamine\Component\RootDependencyDefinition;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Diff\Differ;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Exception\EaLabWebServiceException;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Exception\InvalidConfigException;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Exception\LicenceException;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Exception\LicenceKeyUnsetException;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Exception\WpDeployException;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\File\KeyValueFile;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Import\Importability;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\Config\ConfigProviderManagerBase;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\Config\Exclusions\ExclusionsConfigProviderManager;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\Config\TransmutationHandlerShortcodeReferences\TransmutationHandlerShortcodeReferencesConfigProviderManager;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\Config\ValueHandlerOption\ValueHandlerOptionConfigProviderManager;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\ItemTracker\ItemTrackerBase;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\TrackerConfig\TrackerConfigProviderManager;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\Api;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\CommonDataStorage;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\Database;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\Date;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\Environment;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\FileManager;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\Plugin;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\TrackerConfigUtils;
use Throwable;

/**
 * Tracking is the process of keeping track of the changes made to the data we want to deploy.
 */
class TrackingManager extends ComponentBasedObject {

	public const SETTINGS__ACCESS_TOKEN = 'access_token';

	public const SETTINGS__URL_PROD = 'url_prod';

	/**
	 * File manager.
	 *
	 * @var FileManager|null
	 */
	private ?FileManager $file_manager = null;

	/**
	 * Tells if the item trackers have all been loaded at least once from definitions.
	 *
	 * @var bool
	 */
	private bool $has_loaded_all_item_trackers_once = false;

	/**
	 * The item trackers instances, keyed by their ID.
	 *
	 * @var array<string,ItemTrackerBase>
	 */
	private array $loaded_item_trackers = array();

	/**
	 * The tracker config provider manager.
	 *
	 * @var TrackerConfigProviderManager|null
	 */
	private ?TrackerConfigProviderManager $tracker_config_provider_manager = null;

	/**
	 * Builds the export data.
	 *
	 * @param string[]|null       $uuids The full item UUIDs to load. If null, all eligible items will be loaded.
	 *     If empty array, no item will be exported (= empty bundle).
	 * @param array<string,mixed> $options The options to use for loading the items.
	 *   See TrackableItemCollection::loadTrackableItemsByUuid() for the supported options.
	 *
	 * @return array The export data.
	 *
	 * @throws WpDeployException Error while building the export/import data.
	 */
	public function buildExportData( ?array $uuids = null, array $options = array() ): array {
		// At this point, all the item trackers should have been already loaded.
		// So we do not need to load again from definitions.
		$trackers = $this->getItemTrackers();

		foreach ( $trackers as $tracker ) {
			// Reset the tracker collection to make sure we start with a clean state.
			$tracker->unloadTrackableItems();
		}

		// Get the trackers which handle the given UUIDs.
		$trackers_for_uuids = array();

		if ( null === $uuids ) {
			// A null value means we will load all the eligible items.
			$trackers_for_uuids = $trackers;
		} else {
			// Keep only the trackers which handle the given UUIDs.
			foreach ( $uuids as $uuid ) {
				$t = $this->getItemTrackerByItemFullUuid( $uuid );

				if ( ! empty( $t ) ) {
					$trackers_for_uuids[ $t->getTrackerId() ] = $t;
				}
			}
		}

		// List of items which are not yet loaded.
		/** @var array<TrackableItem> $remaining_list */
		$remaining_list = array();

		foreach ( $trackers_for_uuids as $tracker ) {
			if ( $tracker->isExcluded() ) {
				// Optimization code: we do not need to load any item from this tracker
				// because this tracker (or any of its primary tracker) is excluded.
				// If we ever want to export an item from a tracker which is subordinated
				// to an excluded tracker, we will need to call isExcluded(false)
				// to include this item, AND we will need to prevent the inclusion of
				// the excluded tracker items which are required by this item.
				continue;
			}

			$limit = apply_filters( 'deploy_sync_content__export_limit', null, $tracker->getTrackerId() );

			$options['limit'] = $limit;

			// Load in memory the desired trackable items.
			$loaded = $tracker->loadTrackableItems( $uuids, $options );

			foreach ( $loaded as $item ) {
				$remaining_list[ $item->getUuid( true ) ] = $item;
			}
		}

		// Load all the dependencies of the loaded trackable items.
		// This is not using recursion to avoid stack overflow.
		$already_processed_list = array();

		while ( $remaining_list ) {
			/** @var TrackableItem[] $list_slice */
			$list_slice = array_splice( $remaining_list, 0, 100 );

			foreach ( $list_slice as $full_uuid => $item ) {
				if ( ! empty( $already_processed_list[ $full_uuid ] ) ) {
					continue;
				}

				// This will update the already_processed_list.
				$new_dependencies = $item->processDependencies( $already_processed_list );

				foreach ( $new_dependencies as $dep_full_uuid => $dep ) {
					if ( ! empty( $already_processed_list[ $dep_full_uuid ] ) ) {
						// The new dependency is already processed, so we skip it.
						continue;
					}

					if ( empty( $remaining_list[ $dep_full_uuid ] ) ) {
						// The new dependency is not yet loaded, so we add it to the list.
						$remaining_list[ $dep_full_uuid ] = $dep;
					}
				}
			}
		}

		$data = array();

		foreach ( $trackers as $tracker ) {
			// TODO: remplacer le tableau par un objet BuildData
			//  afin de pouvoir ajouter des infos de build comme les objets rejetés (discarded).
			$tracker->buildExportData( $data );
		}

		return $data;
	}

	/**
	 * Gets the importability values for the given deployment items.
	 *
	 * @param array<array<string,mixed>|TrackableItem> $deployment_items The deployment items to check.
	 * @param array<string,mixed>                      $options The options to use for checking the importability.
	 *     - "against_prod", bool: Whether the check is done against the production environment. Default: false.
	 *       Setting this to true will ask to call the production instance only if the current instance is not
	 *       the production instance.
	 *
	 * @return array<string,int> The importability values, keyed by the item full UUID.
	 *
	 * @throws WpDeployException Error while checking the importability.
	 */
	public function checkImportability( array $deployment_items, array $options = array() ): array {
		try {
			if ( empty( $options['against_prod'] ) || $this->isProductionEnvironment() ) {
				// We want to check the importability against the local environment,
				// or we want to check against the production environment AND we are already on it.
				$importability_values = array();

				foreach ( $deployment_items as $deployment_item ) {
					$item_full_uuid = $this->getUuidFromDeploymentItem( $deployment_item );

					if ( empty( $item_full_uuid ) ) {
						throw new WpDeployException( 'Missing full UUID on an item.' );
					}

					$tracker = $this->getItemTrackerByItemFullUuid( $item_full_uuid );

					if ( empty( $tracker ) ) {
						// The tracker is not found, so we assume this prod instance requires an import
						// to get it installed.
						$importability_values[ $item_full_uuid ] =
							Importability::CAN_IMPORT
							| Importability::REQUIRES_NEW_TRACKER;
						continue;
					}

					// Convert to the exported array format.
					$deployment_item = $deployment_item instanceof TrackableItem ? $deployment_item->buildExportData() : $deployment_item;

					$importability_values[ $item_full_uuid ] = $tracker->checkImportability( $item_full_uuid, $deployment_item );
				}

				return $importability_values;
			}

			// Check against the distant prod instance.
			// We send the local config to let the prod know how to process its own items
			// with the future configuration.
			$deployment_config = ConfigProviderManagerBase::getActiveDeploymentConfig();

			$response_content = Api::callApiProdV2(
				'POST',
				'/wp-json/deploy-sync-content/v1/checkimportability',
				array(
					'data' => json_encode(
						array(
							'deployment_config' => $deployment_config,
							'deployment_items'  => $deployment_items,
						)
					),
				)
			);

			$response_decoded = json_decode( $response_content, true );

			if ( isset( $response_decoded['error'] ) ) {
				// Ideally, the API should return an error HTTP code instead of returning a 2xx code
				// when an error occurs. But for now, we have to check the error message.
				throw new WpDeployException( $response_decoded['error'] );
			}

			if ( ! isset( $response_decoded['importability'] ) ) {
				throw new WpDeployException( 'Missing importability values in the API response.' );
			}

			return $response_decoded['importability'];
		} catch ( \Exception $e ) {
			// Rethrow within a WpDeployException.
			throw new WpDeployException( $e->getMessage(), $e->getCode(), $e );
		}
	}

	/**
	 * Creates the track queue table.
	 */
	public static function createTrackQueueTable(): void {
		// Create the track queue table.
		$wpdb = Database::getTrackingDatabaseHandle();

		Database::doDbDelta(
			$wpdb->prepare(
				<<<'SQL'
CREATE TABLE %i (
	`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
	`operation` tinyint NOT NULL,
	`tracker_id` varchar(64) NOT NULL,
	`local_id` text NOT NULL,
    PRIMARY KEY `id` (`id`)
);
SQL,
				$wpdb->prefix . Database::TRACK_QUEUE_TABLE_NAME
			)
		);
	}

	/**
	 * Drops the track queue table.
	 *
	 * Call this when the plugin is uninstalled.
	 */
	public static function dropTrackQueueTable(): void {
		$wpdb = Database::getTrackingDatabaseHandle();

		$wpdb->query(
			$wpdb->prepare( 'DROP TABLE IF EXISTS %i;', $wpdb->prefix . Database::TRACK_QUEUE_TABLE_NAME )
		);
	}

	/**
	 * Exports the tracked items to the file system using the default configuration.
	 *
	 * - All the loaded item trackers will be used.
	 * - All the trackable items which have been modified will be exported.
	 *
	 * @param array<string>|null  $uuids The UUIDs of the items to export. If null, all the items will be exported.
	 * @param array<string,mixed> $options The options to use for the export.
	 *
	 * @return string The created/edited bundle ID.
	 *
	 * @throws WpDeployException Failure on export.
	 */
	public function export( ?array $uuids = null, array $options = array() ): string {
		$result = $this->buildExportData( $uuids, $options );

		return $this->writeExportData( $result );
	}

	/**
	 * Gets the current environment URL.
	 *
	 * @return string The current environment URL.
	 */
	public function getCurrentEnvironmentUrl(): string {
		return rtrim( get_site_url(), '/' );
	}

	/**
	 * {@inheritDoc}
	 */
	public function getDependencyDefinitions(): array {
		$definitions = parent::getDependencyDefinitions();

		$definitions[] = new RootDependencyDefinition( FileManager::class, $this->file_manager );
		$definitions[] = new RootDependencyDefinition( TrackerConfigProviderManager::class, $this->tracker_config_provider_manager );

		return $definitions;
	}

	/**
	 * Gets the diff for the given item.
	 *
	 * @param array               $incoming_item The incoming item.
	 * @param array<string,mixed> $options The options to use for the diff. Contains the following keys:
	 *   - diff_mode (string): Diff mode.
	 *
	 * @return array The diff results. See ItemTrackerBase::getDiff() for the structure.
	 *
	 * @throws WpDeployException Error on diff process.
	 */
	public function getDiff( array $incoming_item, array $options = array() ): array {
		if ( empty( $incoming_item['uuid'] ) || empty( $incoming_item['tracker'] ) ) {
			return array(
				'result'  => Differ::DIFF_RESULT__ERROR,
				'message' => 'Missing uuid or tracker ID on incoming item.',
			);
		}

		$tracker = $this->getItemTrackerByTrackerId( $incoming_item['tracker'] );

		if ( empty( $tracker ) ) {
			return array(
				'result'  => Differ::DIFF_RESULT__ERROR,
				'message' => 'Tracker not found. Given tracker ID: ' . $incoming_item['tracker'],
			);
		}

		return $tracker->getDiff( $incoming_item, $options );
	}

	/**
	 * Does like getDiff() but for the production environment.
	 *
	 * @param array               $incoming_item The "incoming" item: it's the item that will be incoming when on prod.
	 * @param array<string,mixed> $options The options to use for the diff. Contains the following keys:
	 *   - diff_mode (string): Diff mode.
	 *
	 * @return array The diff results. See ItemTrackerBase::getDiff() for the structure.
	 *
	 * @throws WpDeployException Error on diff process.
	 */
	public function getDiffWithProd( array $incoming_item, array $options = array() ): array {
		if ( empty( $incoming_item['uuid'] ) || empty( $incoming_item['tracker'] ) ) {
			return array(
				'result'  => Differ::DIFF_RESULT__ERROR,
				'message' => 'Missing uuid or tracker ID on incoming item.',
			);
		}

		if ( $this->isProductionEnvironment() ) {
			return array(
				'result'  => Differ::DIFF_RESULT__ERROR,
				'message' => 'Cannot get diff with production environment; we are already on the production instance.',
			);
		}

		// Call the production API to get the diff.
		$response_content = Api::callApiProd(
			'POST',
			'/wp-json/deploy-sync-content/v1/diff',
			array(
				'incoming_item' => $incoming_item,
				'options'       => $options,
			)
		);

		if ( empty( $response_content ) ) {
			return array(
				'result'  => Differ::DIFF_RESULT__ERROR,
				'message' => 'No valid response from production API.',
			);
		}

		$decoded = json_decode( $response_content, true );

		if ( empty( $decoded['diff'] ) ) {
			return array(
				'result'  => Differ::DIFF_RESULT__ERROR,
				'message' => 'Error from production API: ' . ( $decoded->error ?? 'Unknown error.' ),
			);
		}

		return $decoded['diff'];
	}

	/**
	 * Gets a metadata for the given item.
	 *
	 * @param TrackableItem|array<string,mixed> $deployment_item A deployment item.
	 * @param string                            $field_name The field name.
	 *
	 * @return mixed|null The metadata value (nullable), or null if it does not exist.
	 *
	 * @throws WpDeployException Could not get the metadata.
	 *
	 * @deprecated Not used anymore.
	 */
	public function getItemMetadata( $deployment_item, string $field_name ) {
		return $this->getItemTrackerByDeploymentItem( $deployment_item )->getItemMetadata( $deployment_item, $field_name );
	}

	/**
	 * Gets the item tracker for the given tracker ID.
	 *
	 * @param TrackableItem|array<string,mixed> $deployment_item The deployment item,
	 *   either as a TrackableItem instance or as an array.
	 *
	 * @return ItemTrackerBase|null The item tracker, or null if the tracker does not exist or has not been loaded.
	 *
	 * @throws WpDeployException Error while getting the item tracker.
	 */
	public function getItemTrackerByDeploymentItem( $deployment_item ): ?ItemTrackerBase {
		if ( $deployment_item instanceof TrackableItem ) {
			return $deployment_item->getRegistry()->getItemTracker();
		} elseif ( is_array( $deployment_item ) ) {
			$tracker_id = $deployment_item['tracker'] ?? null;

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

			return $this->getItemTrackerByTrackerId( $tracker_id );
		}

		return null;
	}

	/**
	 * Gets the item tracker for the item full UUID.
	 *
	 * @param string $item_full_uuid The item full UUID.
	 *
	 * @return ItemTrackerBase|null The item tracker, or null if the tracker does not exist or has not been loaded.
	 */
	public function getItemTrackerByItemFullUuid( string $item_full_uuid ): ?ItemTrackerBase {
		if ( empty( $item_full_uuid ) ) {
			return null;
		}

		// Extract the tracker ID from the full UUID.
		$parts = explode( '.', $item_full_uuid, 2 );

		if ( count( $parts ) !== 2 ) {
			return null;
		}

		return $this->getItemTrackers()[ $parts[0] ] ?? null;
	}

	/**
	 * Gets the item tracker for the given tracker ID.
	 *
	 * @param string $tracker_id The tracker ID.
	 *
	 * @return ItemTrackerBase|null The item tracker, or null if the tracker does not exist or has not been loaded.
	 */
	public function getItemTrackerByTrackerId( string $tracker_id ): ?ItemTrackerBase {
		if ( empty( $tracker_id ) ) {
			return null;
		}

		return $this->getItemTrackers()[ $tracker_id ] ?? null;
	}

	/**
	 * Gets the item tracker class name for the given tracker ID.
	 *
	 * @param string $tracker_id The tracker ID.
	 *
	 * @return class-string<ItemTrackerBase>|null The item tracker class name, or null if the tracker does not exist.
	 *
	 * @throws InvalidConfigException Could not get a valid active config.
	 * @throws WpDeployException Error while getting the item tracker class name.
	 */
	private function getItemTrackerClassName( string $tracker_id ): ?string {
		if ( empty( $tracker_id ) ) {
			return null;
		}

		$class_names = $this->getItemTrackersDefinitions();

		return $class_names[ $tracker_id ]['class'] ?? ItemTrackerBase::class;
	}

	/**
	 * Gets (loads) all the item trackers.
	 *
	 * @param bool $load_all_item_trackers Whether to (re)load all the item trackers.
	 *
	 * @return array<string,ItemTrackerBase> The item trackers, keyed by their ID.
	 *
	 * @throws InvalidConfigException Could not load a valid active tracker config.
	 * @throws WpDeployException Error while loading the item trackers.
	 */
	private function getItemTrackers( bool $load_all_item_trackers = false ): array {
		if ( $load_all_item_trackers || ! $this->has_loaded_all_item_trackers_once ) {
			$this->has_loaded_all_item_trackers_once = true;

			foreach ( $this->getItemTrackersDefinitions() as $item_tracker_definition ) {
				// loadItemTrackerByDefinition will recursively call getItemTrackers
				// to prevent duplicates. loadItemTrackerByDefinition is public.
				$this->loadItemTrackerByDefinition( $item_tracker_definition );
			}
		}

		return $this->loaded_item_trackers;
	}

	/**
	 * Gets the item trackers definitions.
	 *
	 * @return array The item trackers definitions.
	 *
	 * @throws EaLabWebServiceException Repository web service error.
	 * @throws InvalidConfigException Could not get a valid active config.
	 * @throws LicenceException Licence is invalid.
	 * @throws LicenceKeyUnsetException Licence key is missing.
	 * @throws WpDeployException Error while loading the item trackers.
	 */
	private function getItemTrackersDefinitions(): array {
		// Always reload the config from the provider to prevent outdated data.
		// The provider itself may cache its config, but it's its responsibility.
		return $this->tracker_config_provider_manager->getActiveConfig();
	}

	/**
	 * Gets the active item trackers which are subordinated to the given item tracker.
	 *
	 * @param ItemTrackerBase|string $item_tracker The item tracker or its ID.
	 *
	 * @return array<string,ItemTrackerBase> The item trackers, keyed by their ID.
	 *
	 * @throws WpDeployException Error while loading the item trackers.
	 */
	public function getItemTrackerSubordinatesOf( $item_tracker ): array {
		$subordinates = array();

		foreach ( $this->getItemTrackers() as $loaded_item_tracker ) {
			$primary_item_tracker = $loaded_item_tracker->getPrimaryTracker();

			if ( ! $primary_item_tracker ) {
				continue;
			}

			if ( $primary_item_tracker->getTrackerId() === $item_tracker->getTrackerId() ) {
				$subordinates[ $loaded_item_tracker->getTrackerId() ] = $loaded_item_tracker;
			}
		}

		return $subordinates;
	}

	/**
	 * Gets the Uuid from a deployment item.
	 *
	 * @param TrackableItem|array<string,mixed> $deployment_item The deployment item.
	 * @param bool                              $as_full_uuid Whether to return the full UUID.
	 *
	 * @return string|null The UUID, or null if the UUID could not be retrieved.
	 *
	 * @throws WpDeployException Error while getting the UUID.
	 */
	public function getUuidFromDeploymentItem( $deployment_item, bool $as_full_uuid = true ): ?string {
		if ( $deployment_item instanceof TrackableItem ) {
			return $deployment_item->getUuid( $as_full_uuid );
		} elseif ( is_array( $deployment_item ) ) {
			if ( ! $as_full_uuid ) {
				return $deployment_item['uuid'] ?? null;
			}

			$tracker = $deployment_item['tracker'] ?? null;
			$uuid    = $deployment_item['uuid'] ?? null;

			if ( empty( $tracker ) || empty( $uuid ) ) {
				return null;
			}

			return $tracker . '.' . $uuid;
		}

		return null;
	}

	/**
	 * Initializes the tracking.
	 *
	 * @param array<string,mixed>|null $item_tracker_definitions The item tracker definitions. If left empty, they will
	 *     be retrieved from the current definitions. Beware: if left empty, it could lead to infinite recursion
	 *     if the script which calls this method was reached starting from getItemTrackersDefinitions().
	 * @param array<string,mixed>|null $install_tracking_options The options for the tracking initialization.
	 *     See ItemTrackerBase::installTracking() for the available options.
	 *
	 * @throws EaLabWebServiceException Repository web service error.
	 * @throws InvalidConfigException Could not get a valid active config.
	 * @throws LicenceException Licence is invalid.
	 * @throws LicenceKeyUnsetException Licence key is missing.
	 * @throws WpDeployException Error while initializing the tracking.
	 */
	public function initializeTracking( ?array $item_tracker_definitions = null, ?array $install_tracking_options = array() ): void {
		if ( ! get_option( 'deploy_sync_content__url_prod_is_defined' ) ) {
			// Wait until the plugin is ready to prevent unnecessary tracking.
			return;
		}

		foreach ( ( $item_tracker_definitions ?? $this->getItemTrackersDefinitions() ) as $item_tracker_definition ) {
			$item_tracker = $this->loadItemTrackerByDefinition( $item_tracker_definition );
			$item_tracker->installTracking( $install_tracking_options );
		}

		static::saveTriggerSystemVersion();
	}

	/**
	 * Checks if the given item tracker is subordinated to the other given item tracker.
	 *
	 * @param TrackableItem|array<string,mixed> $subordinate_item The supposed subordinate item.
	 * @param TrackableItem|array<string,mixed> $primary_item The supposed primary item.
	 *
	 * @return bool True if subordinate, false otherwise.
	 *
	 * @throws WpDeployException Error while checking the subordination.
	 */
	public function isDeploymentItemSubordinatedTo( $subordinate_item, $primary_item ): bool {
		// Retrieve the respective trackers.
		$subordinate_tracker  = $this->getItemTrackerByDeploymentItem( $subordinate_item );
		$primary_item_tracker = $this->getItemTrackerByDeploymentItem( $primary_item );

		if ( ! $this->isItemTrackerSubordinatedTo( $subordinate_tracker, $primary_item_tracker ) ) {
			// Check on the tracker level.
			return false;
		}

		// Check on the item level.
		return $subordinate_tracker->isDeploymentItemSubordinatedTo( $subordinate_item, $primary_item );
	}

	/**
	 * Checks if a tracker is subordinated to another tracker.
	 *
	 * @param ItemTrackerBase|string $subordinate_tracker The subordinate tracker.
	 * @param ItemTrackerBase|string $primary_tracker The primary tracker. If left empty, it will tell
	 *   if the subordinate tracker is subordinated to any other tracker.
	 *
	 * @return bool True if the subordinate tracker is subordinated to a tracker, false otherwise.
	 *
	 * @throws WpDeployException Error while checking if the tracker is subordinated.
	 */
	public function isItemTrackerSubordinatedTo( $subordinate_tracker, $primary_tracker = null ): bool {
		if ( is_string( $subordinate_tracker ) ) {
			$subordinate_tracker = $this->getItemTrackerByTrackerId( $subordinate_tracker );
		}

		if ( empty( $subordinate_tracker ) ) {
			throw new WpDeployException( 'Invalid subordinate tracker.' );
		}

		$real_primary_tracker = $subordinate_tracker->getPrimaryTracker();

		if ( empty( $real_primary_tracker ) ) {
			// The subordinate tracker is not subordinated to any other tracker.
			return false;
		}

		if ( empty( $primary_tracker ) ) {
			// We just want to know if the subordinate tracker is subordinated to any other tracker.
			return true;
		}

		// Note: we may just check if $primary_tracker === $real_primary_tracker.
		// But we (the devs) don't have the time to check if PHP will do the right thing with the comparison
		// regarding the performance. So for now, we will just compare the tracker IDs.
		return $primary_tracker->getTrackerId() === $real_primary_tracker->getTrackerId();
	}

	/**
	 * Tells if the current environment is the production environment.
	 *
	 * @return bool True if the current environment is the production environment, false otherwise.
	 */
	public function isProductionEnvironment(): bool {
		return Environment::isProductionEnvironment();
	}

	/**
	 * Loads the item tracker for the given definition.
	 *
	 * If the item tracker is already instantiated, this instance will be returned.
	 *
	 * @param array<string,mixed> $item_tracker_definition The item tracker definition.
	 *
	 * @return ItemTrackerBase The item tracker.
	 *
	 * @throws WpDeployException Error while instantiating the item tracker.
	 */
	public function loadItemTrackerByDefinition( array $item_tracker_definition ): ItemTrackerBase {
		if ( empty( $item_tracker_definition['tracker_id'] ) ) {
			throw new WpDeployException( 'Invalid item tracker definition.' );
		}

		// Check if the item tracker is already instantiated.
		$item_tracker = $this->getItemTrackerByTrackerId( $item_tracker_definition['tracker_id'] );

		if ( $item_tracker ) {
			return $item_tracker;
		}

		try {
			/** @var ItemTrackerBase $item_tracker */
			$item_tracker = $this->addComponentByClassName( $item_tracker_definition['class'] ?? ItemTrackerBase::class );
		} catch ( UnmetDependencyException $e ) {
			// Ignore this manager if it is not ready.
			throw new WpDeployException( 'Error while initializing the item tracker.', 0, $e );
		}

		$this->loaded_item_trackers[ $item_tracker_definition['tracker_id'] ] = $item_tracker;
		$item_tracker->init( $item_tracker_definition );

		return $item_tracker;
	}

	/**
	 * Prettifies the given field name.
	 *
	 * @param string $field_name The field name to prettify.
	 *
	 * @return string The prettified field name.
	 */
	public function prettifyFieldName( string $field_name ): string {
		// Remove the table name prefix, if any.
		$field_name = substr( $field_name, strpos( $field_name, '.' ) + 1 );

		// Replace underscores with spaces.
		$field_name = str_replace( '_', ' ', $field_name );

		// Capitalize the first letter of each word.
		$field_name = ucwords( $field_name );

		return $field_name;
	}

	/**
	 * Refreshes an existing deployment bundle with the given UUIDs.
	 *
	 * @param string              $deployment_bundle_id The deployment bundle ID.
	 * @param string[]            $item_full_uuids_to_add The full UUIDs of the items to add to the deployment bundle.
	 * @param array<string,mixed> $options Options for buildExportData and writeExportData.
	 *     - "buildExportData", array<string,mixed>: options for buildExportData.
	 *     - "writeExportData", array<string,mixed>: options for writeExportData.
	 *
	 * @throws WpDeployException Error while writing the export data.
	 */
	public function refreshDeploymentBundle( string $deployment_bundle_id, array $item_full_uuids_to_add, ?array $options = null ): void {
		if ( empty( $deployment_bundle_id ) ) {
			// Nothing to do.
			return;
		}

		// Retrieve the export data for the items to include.
		$new_items = $this->buildExportData( $item_full_uuids_to_add, $options['buildExportData'] ?? array() );

		// Write the export data (existing items will be replaced) with an updated last_generation_date.
		$this->writeExportData( $new_items, array( 'bundle_id' => $deployment_bundle_id ), $options['writeExportData'] ?? array() );
	}

	/**
	 * Reloads the item trackers against the active config.
	 *
	 * Call this method after setting and unsetting config overrides.
	 *
	 * @throws InvalidConfigException Could not load a valid active tracker config.
	 * @throws WpDeployException Error while loading the item trackers.
	 */
	public function reloadItemTrackers(): void {
		$this->unloadItemTrackers();
		$this->getItemTrackers( true );
	}

	/**
	 * Removes the tracking.
	 *
	 * Alias of removeTracking( null ).
	 *
	 * @throws WpDeployException Error while removing the tracking.
	 */
	public function removeAllTracking(): void {
		$this->removeTracking();
	}

	/**
	 * Removes the specified tracking or all of them.
	 *
	 * @param ItemTrackerBase[]|null $item_trackers The item trackers to remove.
	 *   If null, all the item trackers will be removed. If empty array, nothing will be removed.
	 *
	 * @throws WpDeployException Error while removing the tracking.
	 */
	public function removeTracking( ?array $item_trackers = null ): void {
		if ( null === $item_trackers ) {
			// Remove all the item trackers.
			$item_trackers = $this->getItemTrackers();
		}

		foreach ( $item_trackers as $item_tracker ) {
			$item_tracker->uninstallTracking();

			// Unregister the item tracker from the loaded components.
			$this->removeComponent( $item_tracker );
			unset( $this->loaded_item_trackers[ $item_tracker->getTrackerId() ] );
		}
	}

	/**
	 * Will remove (truncate) all tracking data (UUIDs, dates) of known trackers.
	 *
	 * @param array|null $options Options:
	 *     - "retrack_items", bool: Set to TRUE to retrack items after reset is done.
	 *
	 * @throws Throwable Unmanageable error.
	 * @throws WpDeployException Error while resetting/retracking the items.
	 */
	public function resetTracking( ?array $options = null ): void {
		// Unschedule those actions to limit duplicated tracking calls.
		as_unschedule_all_actions( 'deploy_sync_content/track_item' );

		$item_trackers = $this->getItemTrackers();

		foreach ( $item_trackers as $item_tracker ) {
			$item_tracker->resetTracking();
		}

		if ( ! empty( $options['retrack_items'] ) ) {
			$this->retrackItems();
		}
	}

	/**
	 * This will reset last modification dates of the known trackers.
	 *
	 * @throws InvalidConfigException Cannot load trackers from config.
	 * @throws WpDeployException General Deploy & Sync Content exceptions.
	 */
	public function resetLastModificationDates(): void {
		$wpdb = Database::getTrackingDatabaseHandle();

		foreach ( $this->getItemTrackers() as $tracker ) {
			$tracker_id_metadata_table_name = $tracker->getTrackingMetaDataTableName();

			// COALESCE in case the prod sync date is not set.
			$sql = <<<'SQL'
			UPDATE %i SET `last_modification_date` = COALESCE(`prod_sync_date`, `last_modification_date`);
			SQL;

			$arguments = array(
				$tracker_id_metadata_table_name,
			);

			$prepared = $wpdb->prepare( $sql, $arguments );

			$wpdb->query( $prepared );
		}
	}

	/**
	 * Will enqueue all items for re-tracking (re-indexation).
	 *
	 * @throws WpDeployException Tracker error.
	 */
	public function retrackItems(): void {
		$item_trackers = $this->getItemTrackers();

		foreach ( $item_trackers as $item_tracker ) {
			$item_tracker->retrackItems();
		}
	}

	/**
	 * Saves the trigger system version.
	 *
	 * @throws Throwable Unmanageable exception.
	 */
	public static function saveTriggerSystemVersion(): void {
		$common_data_storage = ComponentBasedObject::getRootComponentByClassName( CommonDataStorage::class, true );

		$common_data_storage->store( 'trigger_system_version', SqlChangesTracker::TRIGGER_SYSTEM_VERSION, 'system_version', true );
	}

	/**
	 * Call this when an exported deployment item requires an update on the merge date.
	 *
	 * @param array  $merged_item The exported deployment item to update which has been merged.
	 *   It does not support TrackableItem instances as value; it only works with the exported data
	 *   because the merge functions work on exported data.
	 * @param string $last_modification_date The last modification date that will be used as the value for merge_date.
	 *
	 * @throws WpDeployException The merge date could not be updated.
	 */
	public function setMergeDate( array &$merged_item, string $last_modification_date ): void {
		$item_tracker = $this->getItemTrackerByDeploymentItem( $merged_item );

		if ( empty( $item_tracker ) ) {
			throw new WpDeployException( 'Invalid item tracker.' );
		}

		$item_tracker->setMergeDate( $merged_item, $last_modification_date );
	}

	/**
	 * Unloads the item trackers.
	 *
	 * @throws WpDeployException Item tracker error.
	 */
	private function unloadItemTrackers(): void {
		// Dev note: make sure that trackable item instances are properly available for garbage collection.
		// If ever needed, we'll have to make a generic "unload" method to have a better lifecycle management.
		foreach ( $this->loaded_item_trackers as $item_tracker ) {
			$item_tracker->unloadTrackableItems();
		}

		$this->removeComponentsByClassName( ItemTrackerBase::class );
		$this->loaded_item_trackers = array();
	}

	/**
	 * Updates the deployment bundle content.json file with the given content.
	 *
	 * Existing values will be merged with the new values.
	 *
	 * @param string $subdirectory The subdirectory of the deployment bundle.
	 * @param array  $content The content to update.
	 *
	 * @throws WpDeployException Error while updating the deployment bundle content.json file.
	 */
	private function updateDeploymentBundleContentJson(
		string $subdirectory,
		array  $content
	): void {
		if ( empty( $subdirectory ) || empty( $content ) ) {
			// Nothing to update.
			return;
		}

		// Get the bundle content.json file.
		$file = $this->file_manager->loadDeploymentBundleMetadata( $subdirectory, 'content.json' );

		if ( empty( $file ) ) {
			// This should never happen because the bundle content.json file should always exist.
			throw new WpDeployException( 'Attempted to update a bundle content.json file, but it does not exist.' );
		}

		// Merge the new values into the existing values.
		$file->mergeValues( $content );

		$this->validateDeploymentBundleContentJson( $file );

		$this->file_manager->saveDeploymentBundleMetadata( $subdirectory, 'content.json', $file );
	}

	/**
	 * Upgrades the data used by Deploy & Sync Content.
	 */
	public static function upgradePluginData(): void {
		// Dev note: It could be a mistake to load the instance here due to side effects on load.
		$instance = static::getRootComponentByClassName( static::class, true );

		$common_data_storage = ComponentBasedObject::getRootComponentByClassName( CommonDataStorage::class, true );

		$current_trigger_system_version = $common_data_storage->loadSingleValue( 'trigger_system_version', 'system_version' );

		if ( SqlChangesTracker::TRIGGER_SYSTEM_VERSION === $current_trigger_system_version ) {
			// The current trigger system is up-to-date.
			return;
		}

		// The versions differ.
		$save_trigger_system_version = static function () use ( $common_data_storage ) {
			$common_data_storage->store( 'trigger_system_version', SqlChangesTracker::TRIGGER_SYSTEM_VERSION, 'system_version' );
		};

		if ( null === $current_trigger_system_version ) {
			// Special case that means the upgrade system is not yet implemented.
			// We then reset the trigger system data for the system implemented in the
			// current version of Deploy & Sync Content.
			foreach ( $instance->getItemTrackers() as $tracker ) {
				$tracker->dropTrackingTriggers();
				$tracker->createTrackingTriggers();
			}

			static::saveTriggerSystemVersion();

			return;
		}

		// We must upgrade the system data.
		// Start by retrieving the upgraders.
		// The upgraders are evaluated in the order of insertion.
		// TODO: implement this system when we'll need it.
		$upgraders = array();

		$upgraders[] = array(
			'from'     => '1.0.0-beta9',
			'to'       => 'The new version here',
			'callable' => static function () {
				// Upgrade from 1.0.0-beta9 to ...
			},
		);
	}

	/**
	 * Validates the deployment bundle content.json file content.
	 *
	 * @param KeyValueFile $file KeyValueFile instance for the content.json file.
	 *
	 * @throws WpDeployException Failed to validate the deployment bundle content.json file.
	 */
	private function validateDeploymentBundleContentJson( KeyValueFile $file ): void {
		$mandatory_fields = array(
			'bundle_id',
			'last_generation_date',
			'import_export_config',
			'active_plugins',
		);

		$current_values = $file->getValues();

		foreach ( $mandatory_fields as $key ) {
			if ( ! isset( $current_values[ $key ] ) ) {
				throw new WpDeployException( 'Missing key "' . $key . '" in the given content.json file.' );
			}
		}
	}

	/**
	 * Writes the whole deployment bundle content JSON file.
	 *
	 * @param string $subdirectory The subdirectory where to write the file.
	 * @param array  $content The content.json content to write as an array.
	 *
	 * @throws WpDeployException Error while writing the deployment bundle content JSON file.
	 */
	private function writeDeploymentBundleContentJson(
		string $subdirectory,
		array  $content
	): void {
		$content_json = new KeyValueFile();

		$content_json->setValues( $content );

		$current_date = Date::getCurrentDateTime()->format( Date::ISO_MILLISECONDS );

		if ( empty( $content_json->getValue( 'last_generation_date' ) ) ) {
			// Set the last generation date to now.
			$content_json->setValue( 'last_generation_date', $current_date );
		}

		if ( empty( $content_json->getValue( 'from_date' ) ) ) {
			// The from_date value is not specified. Try to get it.
			// Check that "from_date" is set if the bundle already exists.
			$maybe_existing_from_date = $this->file_manager->getDeploymentBundleMetadata( $subdirectory, 'from_date' );

			if ( empty( $maybe_existing_from_date ) ) {
				// No "from_date" set.
				// Initialize it to the same value as "last_generation_date".
				$content_json->setValue( 'from_date', $content_json->getValue( 'last_generation_date' ) );
			} else {
				// Reuse it.
				$content_json->setValue( 'from_date', $maybe_existing_from_date );
			}
		}

		if ( empty( $content_json->getValue( 'bundle_label' ) ) ) {
			// The bundle_label value is not specified. Try to get it.
			// Check that "bundle_label" is set if the bundle already exists.
			$maybe_existing_from_date = $this->file_manager->getDeploymentBundleMetadata( $subdirectory, 'bundle_label' );

			if ( ! empty( $maybe_existing_from_date ) ) {
				// Reuse it.
				$content_json->setValue( 'bundle_label', $maybe_existing_from_date );
			}
		}

		$this->validateDeploymentBundleContentJson( $content_json );

		$this->file_manager->saveDeploymentBundleMetadata( $subdirectory, 'content.json', $content_json );
	}

	/**
	 * Writes the export data.
	 *
	 * @param array<string,mixed> $data The export data, coming from $this->buildExportData().
	 * @param array<string,mixed> $metadata The metadata to use when writing the export data. The following options are
	 *     supported:
	 *     - "bundle_id", string: The bundle ID to use. If not set, a new one will be created.
	 *     - "bundle_label", string: The optional bundle label for human use.
	 *     - "exported_by", string: The URL of the instance that exported the data.
	 *       If not set, the current environment URL will be used.
	 *     - "last_generation_date", DateTimeInterface: The last generation date to use.
	 *       If not set, the current date and time will be used.
	 * @param array<string,mixed> $options The options to use when writing the export data. The following options are
	 *     supported:
	 *     - clear_items_before_write: Whether to clear the current items before writing the new ones.
	 *       Defaults to false.
	 *     - full_item_uuids_to_keep: A list of full item UUIDs to keep. When set, all exisiting exported items which
	 *       are not in the list will be deleted.
	 *
	 * @return string The created/edited bundle ID.
	 *
	 * @throws WpDeployException Error while writing the export data.
	 */
	public function writeExportData( array $data, array $metadata = array(), array $options = array() ): string {
		$current_date = Date::getCurrentDateTime();

		$default_metadata = array(
			// The bundleID is the current date and time in the format YYYYMMDDHHMMSSuuuuuu.
			'bundle_id'            => $current_date->format( Date::ISO_COMPACT ),
			'exported_by'          => $this->getCurrentEnvironmentUrl(),
			'last_generation_date' => $current_date->format( Date::ISO_MILLISECONDS ),
		);

		$metadata = array_merge( $default_metadata, $metadata );

		if ( ! empty( $options['clear_items_before_write'] ) ) {
			// Clear the current items if they exist.
			$this->file_manager->clearDeploymentItemsFromBundle( $metadata['bundle_id'] );
		}

		if ( isset( $options['full_item_uuids_to_keep'] ) ) {
			// A list of full item UUIDs has been given.
			// When this list is set, we will remove the existing exported
			// data of the listed items from the bundle files.
			// Please note that it does not prevent *new* data to be saved,
			// even when such data are identified by UUIDs in this list,
			// because they may be added again since they are in $data.
			// This may happen when such item is an item referenced
			// by another item that requires it.
			// Get the current exported items of this bundle.
			$existing_uuids = $this->file_manager->getAllAvailableUuidsForImport( $metadata['bundle_id'] );

			// Get the UUIDs which are not to keep.
			$uuids_to_delete = array_diff( $existing_uuids, $options['full_item_uuids_to_keep'] );

			! empty( $uuids_to_delete ) && $this->file_manager->clearDeploymentItemsFromBundle( $metadata['bundle_id'], $uuids_to_delete );
		}

		foreach ( $data as $key => $value ) {
			// TODO: the write may fail, and we should handle this by logging the error.
			// Write the export data.
			$this->file_manager->saveTrackableItem( $metadata['bundle_id'], $key, $value );
		}

		// Append the tracker config info to the metadata.
		// This is needed because the tracker config is saved as an option,
		// so the user may have excluded it from the export data.
		$current_tracker_config        = $this->tracker_config_provider_manager->getActiveConfig( array( 'remove_config_without_data_tables' => false ) );
		$current_tracker_config_hashes = TrackerConfigUtils::hashCompleteTrackerConfig( $current_tracker_config );

		$metadata['import_export_config']['tracker']['config']        = $current_tracker_config;
		$metadata['import_export_config']['tracker']['config_hashes'] = $current_tracker_config_hashes;

		// Append the other optional configs (value_handler.option, transmutation_handler.shortcode_references).
		// This is a static list for now, but if needed later, we could use the existing hook
		// to dynamically get the classes.
		$other_config_provider_manager_classes = array(
			ExclusionsConfigProviderManager::class,
			TransmutationHandlerShortcodeReferencesConfigProviderManager::class,
			ValueHandlerOptionConfigProviderManager::class,
		);

		foreach ( $other_config_provider_manager_classes as $other_config_provider_manager_class ) {
			/** @var ConfigProviderManagerBase $other_config_provider_manager */
			$other_config_provider_manager = $this->getComponentByClassName( $other_config_provider_manager_class, true, ComponentBucketType::ROOT );
			$other_config                  = $other_config_provider_manager->getActiveConfig() ?? null;

			if ( ! empty( $other_config ) ) {
				$other_config_hashes = TrackerConfigUtils::hashCompleteTrackerConfig( $other_config );

				$metadata['import_export_config'][ $other_config_provider_manager::getManagerId() ]['config']        = $other_config;
				$metadata['import_export_config'][ $other_config_provider_manager::getManagerId() ]['config_hashes'] = $other_config_hashes;
			}
		}

		ksort( $metadata['import_export_config'], SORT_NATURAL );

		// Get the active plugins.
		$metadata['active_plugins'] = Plugin::getActivePlugins();

		ksort( $metadata, SORT_NATURAL );

		// This will create the content.json file (or overwrite it if it already exists).
		$this->writeDeploymentBundleContentJson( $metadata['bundle_id'], $metadata );

		return $metadata['bundle_id'];
	}
}
