<?php
/**
 * Base class for item trackers.
 *
 * @package Ealab\Deployer
 */

namespace Ealab\Deployer\Tracking\ItemTracker;

use Dakwamine\Component\ComponentBasedObject;
use Dakwamine\Component\Exception\UnmetDependencyException;
use Dakwamine\Component\RootDependencyDefinition;
use DateTimeInterface;
use Ealab\Deployer\Diff\Differ;
use Ealab\Deployer\Exception\InvalidConfigException;
use Ealab\Deployer\Exception\WpDeployException;
use Ealab\Deployer\Import\Importability;
use Ealab\Deployer\Tracking\Config\ConfigCollector;
use Ealab\Deployer\Tracking\Config\Exclusions\ExclusionsConfigProviderManager;
use Ealab\Deployer\Tracking\FieldConfig;
use Ealab\Deployer\Tracking\ItemTracker\TrackDateInitializer\TrackDateInitializerBase;
use Ealab\Deployer\Tracking\ItemTracker\TrackDateInitializer\TrackDateInitializerManager;
use Ealab\Deployer\Tracking\ItemValueHandler\ItemValueHandlerBase;
use Ealab\Deployer\Tracking\ItemValueHandler\ItemValueHandlerManager;
use Ealab\Deployer\Tracking\ItemValueHandler\ScalarItemValueHandler;
use Ealab\Deployer\Tracking\SqlChangesTracker;
use Ealab\Deployer\Tracking\SqlOperation;
use Ealab\Deployer\Tracking\TrackableItem;
use Ealab\Deployer\Tracking\TrackableItemCollection;
use Ealab\Deployer\Tracking\TrackingManager;
use Ealab\Deployer\Tracking\TrackScheduler;
use Ealab\Deployer\Utility\Database;
use Ealab\Deployer\Utility\Date;
use Ealab\Deployer\Utility\TrackerConfigUtils;
use Ealab\Deployer\Utility\Uuid;
use Throwable;

/**
 * Base class for item trackers.
 */
class ItemTrackerBase extends ComponentBasedObject {

	/**
	 * The item requires manual intervention to be imported.
	 * @deprecated
	 */
	const IMPORTABILITY__CONFLICT = 'conflict';

	/**
	 * The item is importable.
	 * @deprecated
	 */
	const IMPORTABILITY__IMPORT = 'import';

	/**
	 * The item does not need to be imported.
	 * @deprecated
	 */
	const IMPORTABILITY__SKIP = 'skip';

	/*
	 * Track modes. They control how this item tracker sets the date when tracking an item.
	 */
	const TRACK_MODE__NORMAL       = 'TRACK_MODE__NORMAL';
	const TRACK_MODE__TRACKER_INIT = 'TRACK_MODE__TRACKER_INIT';

	/**
	 * Config collector.
	 *
	 * @var ConfigCollector|null
	 */
	private ?ConfigCollector $config_collector = null;

	/**
	 * Differ for item comparison.
	 *
	 * @var Differ|null
	 */
	protected ?Differ $differ = null;

	/**
	 * Whether this item tracker is excluded from export and import.
	 *
	 * Note: it does not prevent other components from using this tracker.
	 * Note: it does not prevent other components from including the items in their export;
	 * it is the responsibility of the other components to check the tracker's excluded status.
	 *
	 * @var bool
	 */
	protected bool $excluded = false;

	/**
	 * Exclusions config provider manager.
	 *
	 * Used for providing the item_exclusion_patterns config.
	 *
	 * @var ExclusionsConfigProviderManager|null
	 */
	private ?ExclusionsConfigProviderManager $config_provider_manager = null;

	/**
	 * Overrides of the field definitions for this tracker instance.
	 *
	 * The field definitions are arrays keyed by the field name (aka column name).
	 *
	 * Each field definition may have the following keys:
	 * - value_handler: The full handler ID of the value handler to use to retrieve the value of the field.
	 *   E.g. 'scalar' (default), 'reference:option'.
	 *
	 * @var array
	 */
	protected array $field_definitions_overrides = array();

	/**
	 * Arrays of patterns in SQL LIKE format to exclude items from the export.
	 *
	 * Each array is indexed by the short name of the field
	 * which contains the values to inspect against the patterns.
	 *
	 * Use the getter instead of working directly on this property.
	 *
	 * @var null|array<string,array<string>>
	 */
	protected ?array $item_exclusion_patterns = null;

	/**
	 * Local ID definition for this tracker instance.
	 *
	 * This is an array in the following structure:
	 * - name: The name of the field. For composite local IDs, it's a JSON-encoded array of field names, sorted by name.
	 * - type: The SQL type of the field.
	 *   For composite local IDs and by default, it's "text".
	 *   For autoincrement-based trackers, it must match the type of the corresponding field in the source table.
	 *   E.g. for the posts table, it's "bigint(20)" for the "ID" field.
	 *
	 * Note: the type key is only used to define the good column type when creating the table for this tracker.
	 *
	 * @var array<string,mixed>
	 */
	protected array $local_id_definition = array();

	/**
	 * The item value handler manager.
	 *
	 * @var ItemValueHandlerManager|null
	 */
	protected ?ItemValueHandlerManager $item_value_handler_manager = null;

	/**
	 * The plugin which owns this tracker.
	 *
	 * It's in the form "plugin/plugin.php", the one used by activate_plugin().
	 *
	 * If empty, the tracker is considered as a core tracker and will not require any plugin to be active.
	 *
	 * @var string
	 */
	protected string $plugin = '';

	/**
	 * The tracker config sent to the init method.
	 *
	 * Used for getting the hash.
	 *
	 * @var string
	 */
	protected ?array $raw_tracker_config = null;

	/**
	 * Formatter config for the screen label.
	 *
	 * Usually something like array( 'id' => 'field', 'config' => array( 'field_name' => <field_name> ) ).
	 * The content of this array depends on the selected formatter.
	 * See ScreenLabelFormatterBase and its subclasses for more information on the content of the config key.
	 *
	 * If empty, the screen label will be the UUID.
	 *
	 * @var array<string,mixed>
	 */
	protected array $screen_label_formatter_config = array();

	/**
	 * The ID of the tracker this tracker is subordinated to, if any.
	 *
	 * Structure:
	 * - primary_tracker_id (string): The ID of the primary tracker.
	 * - primary_item_id_field (string): The name of the field in this tracker's table
	 *   that contains the ID of the primary item for items in this tracker.
	 *
	 * @var array<string,string>
	 */
	protected array $subordination;

	/**
	 * Tells if the tables exist. Use the checkTablesExistence() method instead of this property.
	 *
	 * @var bool|null
	 */
	private ?bool $tables_exist = null;

	/**
	 * Track date initializer component.
	 *
	 * Set on init().
	 *
	 * @var TrackDateInitializerBase
	 */
	private TrackDateInitializerBase $track_date_initializer;

	/**
	 * Track date initializer manager.
	 *
	 * @var TrackDateInitializerManager|null
	 */
	private ?TrackDateInitializerManager $track_date_initializer_manager = null;

	/**
	 * Track scheduler.
	 *
	 * @var TrackScheduler|null
	 */
	protected ?TrackScheduler $track_scheduler = null;

	/**
	 * The trackable item registry.
	 *
	 * @var TrackableItemCollection
	 */
	protected TrackableItemCollection $trackable_item_registry;

	/**
	 * The tracker config, also known as tracker definition.
	 *
	 * Contains the configuration when this tracker has been initialized.
	 *
	 * @var array<string,mixed>
	 */
	protected array $tracker_config = array();

	/**
	 * The tracker config hash.
	 *
	 * @var string
	 */
	protected string $tracker_config_hash;

	/**
	 * The tracker ID.
	 *
	 * @var string
	 */
	protected string $tracker_id;

	/**
	 * The tracking manager.
	 *
	 * @var TrackingManager|null
	 */
	private ?TrackingManager $tracking_manager = null;

	/**
	 * The UUID service.
	 *
	 * @var Uuid|null
	 */
	private ?Uuid $uuid = null;

	/**
	 * Tells if we want a random UUID generation for the items.
	 *
	 * @var bool|null
	 */
	private ?bool $uuid_random_generation = null;

	/**
	 * The UUID source fields.
	 *
	 * Contains a list of field names that are used to generate the UUID.
	 * If empty, other generation strategies will be used.
	 * This is used by the options tracker where option_name serves as the UUID source.
	 *
	 * When $uuid_random_generation is true, this is ignored.
	 *
	 * @var array<string>|null
	 */
	private ?array $uuid_source_fields = null;

	/**
	 * Builds the values on the given trackable item.
	 *
	 * @param TrackableItem       $trackable_item The trackable item.
	 * @param array               $fields The fields to assign.
	 * @param int                 $origin The origin of the values to assign.
	 *   One of the ItemValueHandlerBase::ORIGIN_* constants.
	 * @param array<string,mixed> $options Options for the assignment. Keys:
	 *   - include_local_id (bool): Whether to include the local ID in the assigned values. Default: false.
	 *       If the local ID is not included in the fields, it will be retrieved from the trackable item if it exists.
	 *
	 * @throws WpDeployException Cannot assign the values.
	 */
	public function assignTrackableItemValues( TrackableItem $trackable_item, array $fields, int $origin, array $options = array() ): void {
		$include_local_id = $options['include_local_id'] ?? false;

		$local_id_field_name       = $this->getLocalIdFieldName();
		$uses_composite_local_id   = $this->usesCompositeLocalId();
		$tmd_table_name_no_prefix  = $this->getTrackingMetaDataTableName( false );
		$data_table_name_no_prefix = $this->getDataTableName( false );

		if ( $include_local_id && ! $uses_composite_local_id && empty( $fields[ $local_id_field_name ] ) ) {
			/**
			 * The local ID is not set in the fields, so we attempt to retrieve it.
			 * This happens in import scenarios, where the autoincrement local ID is not included
			 * in the fields.
			 *
			 * Composite local ID source fields must always be exported and available,
			 * so we don't need to handle them here.
			 * If we ever need to retrieve the local ID field values for composite local IDs, we can do it
			 * by using $this->getCompositeLocalIdValues( $local_id );. But if there are reference
			 * fields as composite local ID source fields, they will require a translation to full item UUID
			 * when $origin === ORIGIN__IMPORT_FILE, because it's expected that the reference fields
			 * contain the UUIDs of the referenced items when their values are imported from the export file.
			 */
			$local_id = $trackable_item->getLocalId();

			if ( ! empty( $local_id ) ) {
				// The local ID is set on the trackable item, so we add it to the fields.
				$fields[ $tmd_table_name_no_prefix . '.local_id' ] = $local_id;

				$fields[ $data_table_name_no_prefix . '.' . $local_id_field_name ] = $local_id;
			}
		}

		// Reset the trackable item values.
		$trackable_item->resetValues();

		// Get the field data definitions specific to this tracker.
		// We use the definitions to determine the type of the data of each field.
		// A type of data symbolizes how its value should be handled;
		// it's not related to the type of the value itself (int, bool, string, etc).
		// For example, we have a type of data for "option", which means that the value
		// is processed by the specific OptionItemValueHandler.
		// When no type of data is found for a field, we use the default type of data,
		// which is "scalar".
		$field_definitions = $this->getFieldDefinitions();

		// Set raw field data to the trackable item.
		// This is useful if value handlers need information about its container.
		// E.g. the option value field which needs to inspect the option name to determine the value handler.
		// First, separate between metadata and data.
		$metadata_fields = array();
		$data_fields     = array();
		$this->separateDataFromMetadata( $fields, $metadata_fields, $data_fields, false );
		$trackable_item->setRawMetadataFields( $metadata_fields );
		$trackable_item->setRawDataFields( $data_fields );

		// Set the value handlers on the trackable item for each field it contains that we want to compare.
		foreach ( $fields as $full_field_name => $value ) {
			if ( $full_field_name === $tmd_table_name_no_prefix . '.id' ) {
				// Ignore the autoincremented ID of the TMD table.
				continue;
			}

			// Parse the full field name to retrieve the table name and the column name.
			$parts = explode( '.', $full_field_name );

			$table_name = $parts[0] ?? null;
			$field_name = $parts[1] ?? null;

			if ( ! $include_local_id && ( $full_field_name === $tmd_table_name_no_prefix . '.local_id' ) ) {
				// Ignore the local ID value of the TMD table.
				continue;
			}

			if ( ! $include_local_id
				&& ( ! $uses_composite_local_id && ( ( $data_table_name_no_prefix . '.' . $local_id_field_name ) === $full_field_name ) ) ) {
				// Ignore the local ID value of the data table (relevant for non-composite local ID based trackers).
				// Composite local ID based trackers may have reference fields as local ID source;
				// such fields still require to be exported, and will be converted to UUIDs.
				continue;
			}

			if ( empty( $table_name ) || empty( $field_name ) ) {
				throw new WpDeployException( sprintf( 'Failed to parse the field name. Given field name was: %s', $full_field_name ) );
			}

			$field_config = new FieldConfig();

			if ( $table_name === $tmd_table_name_no_prefix ) {
				// The metadata fields are always scalars and don't have processors.
				// If we ever need to change this, we'll need a separate hook
				// to determine the value handler for metadata fields,
				// to prevent collisions with the data fields.
				$field_config->init( array( 'value_handler' => ScalarItemValueHandler::getHandlerBaseId() ) );
			} else {
				$field_config->init( $field_definitions[ $field_name ] ?? array() );
			}

			$trackable_item->setValue( $trackable_item, $full_field_name, $value, $origin, $field_config );
		}
	}

	/**
	 * Build a CONCAT query to build a PHP serialized array that will contain the composite local ID fields & values.
	 *
	 * The following example shows what is built for a composite local ID with 2 fields:
	 *
	 * @code
	 * CONCAT('a:2:{s:10:"field_name";s:',LENGTH(NEW.field_name),':"',NEW.field_name,'";s:11:"field_other";s:',LENGTH(NEW.field_other),':"',NEW.field_other,'";}')
	 * @endcode
	 *
	 * @param string|null         $query Set by reference. The value to be added in the query.
	 * @param array|null          $arguments Set by reference. The arguments to be added in the query.
	 * @param array<string,mixed> $options The options to be used in the query. Available options:
	 *     - "value_prefix" (string): The value prefix to be used in the query, either 'NEW.' or 'OLD.' or empty
	 *       string. NEW. and OLD. are used when the query is used in a SQL trigger.
	 *     - "table_name" (string): The table name to be used in the query. This is used to prefix the field names.
	 *       This value is ignored if value_prefix is set.
	 *
	 * @throws WpDeployException If the local ID field names are not set.
	 */
	public function buildCompositeLocalIdValuesQuery( ?string &$query, ?array &$arguments, array $options = array() ): void {
		// TODO: if this method is used too often, we should consider caching the result with ResultsCache.
		$query     = $query ?? '';
		$arguments = $arguments ?? array();

		$value_prefix = $options['value_prefix'] ?? '';

		// Table name is only used when the given value prefix is empty.
		$table_name = empty( $value_prefix ) ? ( $options['table_name'] ?? '' ) : '';

		// The field names are already sorted by the field name (see init()).
		$local_id_fields = $this->getCompositeLocalIdFieldNames();

		if ( empty( $local_id_fields ) ) {
			throw new WpDeployException( 'The local ID field names are not set.' );
		}

		$local_id_fields_count = count( $local_id_fields );

		$query .= "CONCAT('a:$local_id_fields_count:{',";

		// Each concatenation part is an argument of the CONCAT function.
		$concat_parts = array();

		foreach ( $local_id_fields as $local_id_field ) {
			// The parts of the concatenation for each field.
			$field_concat_parts = array();

			// Length and name of the field.
			// Ex.: "s:10:"field_name";s:".
			$field_concat_parts[] = '%s';

			$arguments[] = 's:' . strlen( $local_id_field ) . ':"' . $local_id_field . '";s:';

			// Length of the value.
			if ( ! empty( $table_name ) ) {
				// Ex.: "LENGTH(`table_name`.`field_name`)".
				$field_concat_parts[] = 'LENGTH(%i.%i)';

				$arguments[] = $table_name;
			} else {
				// Ex.: "LENGTH(NEW.`field_name`)".
				$field_concat_parts[] = 'LENGTH(' . $value_prefix . '%i)';
			}

			$arguments[] = $local_id_field;

			// Separates the field length and the value.
			$field_concat_parts[] = '%s';

			$arguments[] = ':"';

			// Field value.
			if ( ! empty( $table_name ) ) {
				// Ex.: "`table_name`.`field_name`".
				$field_concat_parts[] = '%i.%i';

				$arguments[] = $table_name;
			} else {
				// Ex.: "NEW.`field_name`".
				$field_concat_parts[] = $value_prefix . '%i';
			}

			$arguments[] = $local_id_field;

			$field_concat_parts[] = '%s';

			$arguments[] = '";';

			$concat_parts[] = implode( ',', $field_concat_parts );
		}

		// Close the serialized array.
		$concat_parts[] = '%s';

		$arguments[] = '}';

		$query .= implode( ',', $concat_parts );

		// Close the CONCAT function.
		$query .= ')';
	}

	/**
	 * Builds the data to export/import.
	 *
	 * @param array<string,array> $build The array containing the data from the trackable items of this tracker
	 *   keyed by their full ID.
	 *
	 * @throws WpDeployException Various errors on registry and value retrieval.
	 */
	public function buildExportData( array &$build ): void {
		if ( $this->isExcluded() ) {
			return;
		}

		foreach ( $this->getTrackableItemRegistry()->getCollectedTrackableItems() as $trackable_item ) {
			// Dev note: This is where we filter out the trackable items that we don't want to export.
			if ( ! $trackable_item->hasValidDependencies() ) {
				// Ignore erroneous trackable items.
				// TODO: log removed items and the reason why they were removed.
				continue;
			}

			if ( $trackable_item->isExcludedFromExportImport() ) {
				// Ignore excluded items.
				continue;
			}

			$build[ $trackable_item->getFullItemId() ] = $trackable_item->buildExportData();
		}
	}

	/**
	 * Builds the local ID of the given item.
	 *
	 * @param TrackableItem $trackable_item The item to build the local ID for.
	 *
	 * @return string The local ID of the item, as a serialized array.
	 *
	 * @throws WpDeployException Invalid local ID definition.
	 */
	private function buildItemCompositeLocalId( TrackableItem $trackable_item ): string {
		// Local ID fields are supposed to be sorted by name, so we can serialize them in a predictable way.
		$local_id_fields = $this->getCompositeLocalIdFieldNames();

		$local_id = array();

		// Get the field values, as it would be during the finalization of the import.
		// This is necessary to get the real local IDs of reference fields, not their UUIDs.
		$fields = $trackable_item->getFieldValues( ItemValueHandlerBase::OPERATION__IMPORT__FINALIZE );

		$tracker_id = $this->getTrackerId();

		foreach ( $local_id_fields as $local_id_field ) {
			// We need to cast the field value to string, because it could be an integer or a float.
			// This is a requirement to get the same serialized array for the same data,
			// be it generated from the database (the CONCAT thing) or built in PHP.
			$local_id[ $local_id_field ] = (string) $fields[ $tracker_id . '.' . $local_id_field ];
		}

		if ( empty( $local_id ) ) {
			throw new WpDeployException( 'Could not build local ID definition for item ' . $trackable_item->getUuid( true ) );
		}

		return serialize( $local_id );
	}

	/**
	 * Checks if the given item can be imported, and tells what to do.
	 *
	 * @param string $item_full_uuid The full UUID of the item to import.
	 * @param array  $incoming_item The item payload from the import file.
	 *
	 * @return int Contains the bitwise result of the importability check. See Importability::* constants.
	 *
	 * @throws WpDeployException Usually an error due to an invalid tracker definition.
	 */
	public function checkImportability( string $item_full_uuid, array $incoming_item ): int {
		// Bitwise result.
		$importability_result = 0;

		if ( ! $this->checkTablesExistence() ) {
			// The tables do not exist.
			// The item will be marked as a new importable item.
			// We expect the tables to be created right after the activation of the plugin that manages them.
			// This case happens when a bundle contains items from a tracker embedded in the bundle,
			// but the plugin that manages the tracker has not yet initialized the data table.
			return $importability_result | Importability::CAN_IMPORT | Importability::HAS_CHANGES_TO_IMPORT;
		}

		$tracker_config_hash = $incoming_item['tracker_config_hash'] ?? null;

		if ( $tracker_config_hash !== $this->getConfigHash() ) {
			// The tracker config hash is different. This means this instance requires an import
			// of the new tracker config before we can suggest any action such as diff and merge.
			return Importability::TRACKER_CONFIG_MISMATCH;
		}

		// Get the current version in database.
		$wpdb = Database::getTrackingDatabaseHandle();

		$result =
			$wpdb->get_row(
				$wpdb->prepare(
					'SELECT `last_modification_date`, `prod_sync_date` FROM %i WHERE `uuid` = %s;',
					$this->getTrackingMetaDataTableName(),
					$this->stripReferencePrefixFromUuid( $item_full_uuid )
				),
				ARRAY_A
			);

		if ( empty( $result['last_modification_date'] ) || empty( $result['prod_sync_date'] ) ) {
			// The item does not exist in the database,
			// or the metadata are incomplete (which should never happen)
			// Import the item without further checks.
			return $importability_result | Importability::CAN_IMPORT | Importability::HAS_CHANGES_TO_IMPORT;
		}

		$importability_result |= Importability::LOCAL_ITEM_EXISTS;

		// The item already exists.
		// We now determine if the incoming item must be imported.
		$tmd_table_name_no_prefix             = $this->getTrackingMetaDataTableName( false );
		$local_item_last_modification_date    = $result['last_modification_date'];
		$local_item_prod_sync_date            = $result['prod_sync_date'];
		$incoming_item_last_modification_date = $incoming_item['fields'][ $tmd_table_name_no_prefix . '.last_modification_date' ];
		$incoming_item_prod_sync_date         = $incoming_item['fields'][ $tmd_table_name_no_prefix . '.prod_sync_date' ];
		$incoming_item_merge_date             = $incoming_item['fields'][ $tmd_table_name_no_prefix . '.merge_date' ] ?? null;

		// All dates are in the UTC timezone, so we can compare them as is.
		if ( $local_item_prod_sync_date === $incoming_item_prod_sync_date ) {
			$importability_result |= Importability::BASED_ON_SAME_PRODUCTION_VALUES;
		}

		if ( $local_item_last_modification_date === $incoming_item_last_modification_date ) {
			$importability_result |= Importability::SAME_LAST_MODIFICATION_DATE;
		}

		if ( $local_item_last_modification_date === $incoming_item_merge_date ) {
			$importability_result |= Importability::MERGE_IS_VALID;
		}

		if ( $local_item_last_modification_date !== $local_item_prod_sync_date ) {
			$importability_result |= Importability::LOCAL_ITEM_HAS_BEEN_MODIFIED;
		}

		if ( $incoming_item_last_modification_date !== $incoming_item_prod_sync_date ) {
			$importability_result |= Importability::INCOMING_ITEM_HAS_BEEN_MODIFIED;
		}

		if ( $importability_result & Importability::SAME_LAST_MODIFICATION_DATE ) {
			// No changes to import, but can be imported.
			return $importability_result | Importability::CAN_IMPORT;
		}

		// Changes to import.
		$importability_result |= Importability::HAS_CHANGES_TO_IMPORT;

		if ( $importability_result & Importability::MERGE_IS_VALID ) {
			// The incoming item has been merged, so it's OK to import it.
			return $importability_result | Importability::CAN_IMPORT;
		}

		if ( ( $importability_result & Importability::BASED_ON_SAME_PRODUCTION_VALUES )
			&& ! ( $importability_result & Importability::LOCAL_ITEM_HAS_BEEN_MODIFIED ) ) {
			// The local item has not been modified, so we can import the incoming item.
			return $importability_result | Importability::CAN_IMPORT;
		}

		// All other cases require a merge.
		return $importability_result;
	}

	/**
	 * Checks that the data and metadata tables exist.
	 *
	 * @return bool true if they exist.
	 *
	 * @throws WpDeployException Tracker configuration errors.
	 */
	private function checkTablesExistence(): bool {
		if ( null !== $this->tables_exist ) {
			return $this->tables_exist;
		}

		$table_names = array( $this->getDataTableName(), $this->getTrackingMetaDataTableName() );

		foreach ( $table_names as $table_name ) {
			if ( ! Database::tableExists( $table_name ) ) {
				$this->tables_exist = false;

				return $this->tables_exist;
			}
		}

		$this->tables_exist = true;

		return $this->tables_exist;
	}

	/**
	 * Creates the triggers which will tell about changes in the database.
	 *
	 * @throws WpDeployException Error during creation of the triggers.
	 */
	public function createTrackingTriggers(): void {
		$wpdb = Database::getTrackingDatabaseHandle();

		$tracker_id = $this->getTrackerId();

		$local_id_value_for_trigger = '';

		if ( ! $this->usesCompositeLocalId() ) {
			// This is the simplest case. We only have a single local ID field.
			$local_id_value_for_trigger = 'NEW.%i';

			$local_id_arguments[] = $this->getLocalIdFieldName();
		} else {
			$this->buildCompositeLocalIdValuesQuery(
				$local_id_value_for_trigger,
				$local_id_arguments,
				array( 'value_prefix' => 'NEW.' )
			);
		}

		$sql = <<<'SQL'
CREATE TRIGGER %i AFTER INSERT ON %i FOR EACH ROW
BEGIN
    IF @no_tracking IS NULL OR @no_tracking < 1 THEN INSERT INTO %i (`operation`, `tracker_id`, `local_id`) VALUES (%s, %s, 
SQL;

		$sql .= $local_id_value_for_trigger;
		$sql .= '); END IF; END;';

		$arguments = array_merge(
			array(
				Database::getHashedTriggerName( 'after_' . $tracker_id . '_insert' ),
				$wpdb->prefix . $tracker_id,
				$wpdb->prefix . Database::TRACK_QUEUE_TABLE_NAME,
				SqlOperation::INSERT,
				$tracker_id,
			),
			$local_id_arguments
		);

		$wpdb->query( $wpdb->prepare( $sql, $arguments ) );

		$sql = <<<'SQL'
CREATE TRIGGER %i AFTER UPDATE ON %i FOR EACH ROW
BEGIN
    IF @no_tracking IS NULL OR @no_tracking < 1 THEN 
SQL;

		$arguments = array(
			Database::getHashedTriggerName( 'after_' . $tracker_id . '_update' ),
			$wpdb->prefix . $tracker_id,
		);

		// For the UPDATE trigger, we will check that the values have really changed.
		// If identical, we will not track the item; thus not updating its last modification date.
		// Get the columns of the data table to build the condition which will prevent tracking
		// when the row data has not changed after an update.
		$all_column_names = Database::getAllColumnNames( $this->getDataTableName(), $wpdb );

		$if_values_changed_condition_start = array();

		foreach ( $all_column_names as $column_name ) {
			$if_values_changed_condition_start[] = 'OLD.%i <=> NEW.%i';

			$arguments[] = $column_name;
			$arguments[] = $column_name;
		}

		// Something like: 'OLD.%i <=> NEW.%i AND OLD.%i <=> NEW.%i'.
		$if_values_changed_condition_start = implode( ' AND ', $if_values_changed_condition_start );
		$if_values_changed_condition_start = empty( $if_values_changed_condition_start ) ? '' : 'IF !(' . $if_values_changed_condition_start . ') THEN ';
		$if_values_changed_condition_end   = empty( $if_values_changed_condition_start ) ? '' : ' END IF; ';

		// Something like: 'IF !(OLD.%i <=> NEW.%i AND OLD.%i <=> NEW.%i) THEN '.
		$sql .= $if_values_changed_condition_start;
		$sql .= 'INSERT INTO %i (`operation`, `tracker_id`, `local_id`) VALUES (%s, %s, ';

		$arguments[] = $wpdb->prefix . Database::TRACK_QUEUE_TABLE_NAME;
		$arguments[] = SqlOperation::UPDATE;
		$arguments[] = $tracker_id;

		$sql .= $local_id_value_for_trigger;
		$sql .= ');';
		$sql .= $if_values_changed_condition_end;
		$sql .= ' END IF; END;';

		$arguments = array_merge(
			$arguments,
			$local_id_arguments
		);

		$wpdb->query( $wpdb->prepare( $sql, $arguments ) );

		$sql = <<<'SQL'
CREATE TRIGGER %i AFTER DELETE ON %i FOR EACH ROW
BEGIN
    IF @no_tracking IS NULL OR @no_tracking < 1 THEN INSERT INTO %i (`operation`, `tracker_id`, `local_id`) VALUES (%s, %s, 
SQL;

		$sql .= $local_id_value_for_trigger;
		$sql .= '); END IF; END;';

		// Replace NEW.%i with OLD.%i for the DELETE trigger.
		$sql = str_replace( 'NEW.%i', 'OLD.%i', $sql );

		$arguments = array_merge(
			array(
				Database::getHashedTriggerName( 'after_' . $tracker_id . '_delete' ),
				$wpdb->prefix . $tracker_id,
				$wpdb->prefix . Database::TRACK_QUEUE_TABLE_NAME,
				SqlOperation::DELETE,
				$tracker_id,
			),
			$local_id_arguments
		);

		$wpdb->query( $wpdb->prepare( $sql, $arguments ) );
	}

	/**
	 * Drop the tracking triggers.
	 *
	 * It should be safe to call this even if the triggers do not exist.
	 *
	 * @throws WpDeployException Error during removal.
	 */
	public function dropTrackingTriggers(): void {
		$wpdb = Database::getTrackingDatabaseHandle();

		$tracker_id = $this->getTrackerId();

		$trigger_names = array(
			'after_' . $tracker_id . '_insert',
			'after_' . $tracker_id . '_update',
			'after_' . $tracker_id . '_delete',
		);

		foreach ( $trigger_names as $trigger_name ) {
			$wpdb->query(
				$wpdb->prepare(
					'DROP TRIGGER IF EXISTS %i;',
					Database::getHashedTriggerName( $trigger_name )
				)
			);
		}
	}

	/**
	 * Gets the local ID field names for composite ID based trackers.
	 *
	 * @return array<string> The local ID field names as array.
	 *
	 * @throws WpDeployException If the local ID field names are not valid.
	 */
	public function getCompositeLocalIdFieldNames(): array {
		$decoded = json_decode( $this->getLocalIdFieldName(), true );

		if ( ! is_array( $decoded ) ) {
			throw new WpDeployException( 'Not valid composite local ID field names.' );
		}

		return $decoded;
	}

	/**
	 * Gets the local ID values from the PHP serialized string.
	 *
	 * @param string $serialized_local_id_values The PHP serialized local ID values.
	 *
	 * @return array The local ID values, as an associative array. Indexes are the local ID field names.
	 *
	 * @throws WpDeployException If the serialized local ID values are not valid.
	 */
	public function getCompositeLocalIdValues( string $serialized_local_id_values ): array {
		// Unserialize. Composite local ID values are stored as a PHP serialized array.
		// This is because we want MySQL to be able to build such a composite key
		// in some complex cases, and the PHP serialization format is easy to build.
		$local_id_values = unserialize( $serialized_local_id_values );

		if ( ! is_array( $local_id_values ) ) {
			throw new WpDeployException( 'Not a valid composite local ID value.' );
		}

		return $local_id_values;
	}

	/**
	 * Gets the hash of the tracker configuration.
	 *
	 * @return string The hash.
	 */
	public function getConfigHash(): string {
		if ( ! $this->tracker_config_hash ) {
			$this->tracker_config_hash = TrackerConfigUtils::buildTrackerConfigHash( $this->raw_tracker_config );
		}

		return $this->tracker_config_hash;
	}

	/**
	 * Gets the main data table name for this tracker.
	 *
	 * @param bool $with_prefix Whether to include the database prefix in the table name.
	 *
	 * @return string The table name.
	 *
	 * @throws WpDeployException If the tracker ID is not set.
	 */
	public function getDataTableName( bool $with_prefix = true ): string {
		return ( $with_prefix ? Database::getTrackingDatabaseHandle()->prefix : '' ) . $this->getTrackerId();
	}

	/**
	 * {@inheritDoc}
	 */
	public function getDependencyDefinitions(): array {
		$definitions   = parent::getDependencyDefinitions();
		$definitions[] = new RootDependencyDefinition( ConfigCollector::class, $this->config_collector );
		$definitions[] = new RootDependencyDefinition( Differ::class, $this->differ );
		$definitions[] = new RootDependencyDefinition( ExclusionsConfigProviderManager::class, $this->config_provider_manager );
		$definitions[] = new RootDependencyDefinition( ItemValueHandlerManager::class, $this->item_value_handler_manager );
		$definitions[] = new RootDependencyDefinition( TrackDateInitializerManager::class, $this->track_date_initializer_manager );
		$definitions[] = new RootDependencyDefinition( TrackScheduler::class, $this->track_scheduler );
		$definitions[] = new RootDependencyDefinition( TrackingManager::class, $this->tracking_manager );
		$definitions[] = new RootDependencyDefinition( Uuid::class, $this->uuid );

		return $definitions;
	}

	/**
	 * Gets the diff for the given item.
	 *
	 * @param array               $incoming_item The incoming item.
	 * @param array<string,mixed> $options The options for the diff. Contains the following keys:
	 *   - diff_mode (string): The diff mode.
	 *
	 * @return array The diff results. Structure:
	 *   - result: The result of the diff. One of the Differ::DIFF_RESULT__* constants.
	 *   - message: The message associated with the result when an error occurred.
	 *   - diff: The diff data when the result is Differ::DIFF_RESULT__MODIFIED.
	 *
	 * @throws WpDeployException Error on diff process.
	 */
	public function getDiff( array $incoming_item, array $options = array() ): array {
		$uuid = $incoming_item['uuid'] ?? null;

		if ( empty( $uuid ) ) {
			// The incoming item does not have a UUID.
			// In theory, this should never happen.
			return array(
				'result'  => Differ::DIFF_RESULT__ERROR,
				'message' => esc_html( 'The incoming item to compare with does not have a UUID, or the given item is not handled by the ' . $this->getTrackerId() . ' tracker.' ),
			);
		}

		$tracker = $incoming_item['tracker'] ?? null;

		if ( $tracker !== $this->getTrackerId() ) {
			// The incoming item is not handled by this tracker.
			// In theory, this should never happen.
			return array(
				'result'  => Differ::DIFF_RESULT__ERROR,
				'message' => esc_html( 'The incoming item to compare with is not handled by the ' . $this->getTrackerId() . ' tracker.' ),
			);
		}

		try {
			// Get the matching local item by UUID.
			$local_trackable_item = $this->loadTrackableItem( $uuid );
		} catch ( WpDeployException $e ) {
			return array(
				'result'  => Differ::DIFF_RESULT__ERROR,
				'message' => $e->getMessage(),
			);
		}

		if ( empty( $local_trackable_item ) ) {
			// The local item does not exist.
			// We assume that the incoming item is a new item.
			$local_item = array( 'fields' => array() );

			$differ_result = $this->differ->compareItem( $local_item, $incoming_item, $options );

			return array(
				'result'   => Differ::DIFF_RESULT__NEW,
				'diff'     => $differ_result,
				'metadata' => MetadataForDiff::extractFromItems( $this, $local_item, $incoming_item ),
			);
		}

		try {
			$local_item = $local_trackable_item->buildExportData();
		} catch ( WpDeployException $e ) {
			return array(
				'result'  => Differ::DIFF_RESULT__ERROR,
				'message' => $e->getMessage(),
			);
		}

		try {
			$differ_result = $this->differ->compareItem( $local_item, $incoming_item, $options );
		} catch ( WpDeployException $e ) {
			return array(
				'result'  => Differ::DIFF_RESULT__ERROR,
				'message' => $e->getMessage(),
			);
		}

		if ( empty( $differ_result ) ) {
			// There is no difference between the local item and the incoming item.
			return array(
				'result'   => Differ::DIFF_RESULT__SAME,
				'metadata' => MetadataForDiff::extractFromItems( $this, $local_item, $incoming_item ),
			);
		}

		// The item has been modified.
		return array(
			'result'   => Differ::DIFF_RESULT__MODIFIED,
			'diff'     => $differ_result,
			'metadata' => MetadataForDiff::extractFromItems( $this, $local_item, $incoming_item ),
		);
	}

	/**
	 * Allows to specify field definitions.
	 *
	 * @return array The trackable items definitions.
	 */
	public function getFieldDefinitions(): array {
		return $this->field_definitions_overrides;
	}

	/**
	 * The patterns in SQL LIKE format to exclude items from the export.
	 *
	 * Indexed by short field name.
	 *
	 * @return array<string,array<string>> The patterns.
	 *
	 * @throws InvalidConfigException Invalid exclusion config.
	 * @throws WpDeployException Common error.
	 */
	public function getItemExclusionPatterns(): array {
		if ( ! is_array( $this->item_exclusion_patterns ) ) {
			// Retrieve the config.
			$this->item_exclusion_patterns = array();

			$configs = $this->config_collector->collect( $this->config_provider_manager, 'exclusions' );

			$tracker_id = $this->getTrackerId();

			if ( empty( $configs[ $tracker_id ] ) ) {
				// No exclusion patterns for this tracker.
				return $this->item_exclusion_patterns;
			}

			foreach ( $configs[ $tracker_id ] as $full_field_name => $patterns ) {
				$short_field_name = Database::removePrefix( $full_field_name, $this->getTrackerId() . '.' );

				if ( $short_field_name === $full_field_name ) {
					// Invalid field name.
					continue;
				}

				foreach ( $patterns as $pattern ) {
					$this->item_exclusion_patterns[ $short_field_name ][ $pattern ] = $pattern;
				}
			}
		}

		return $this->item_exclusion_patterns;
	}

	/**
	 * 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 Seems not used. To be removed.
	 */
	public function getItemMetadata( $deployment_item, string $field_name ) {
		// TODO: peut-être retourner une valeur construite pour local_id composite.
		// TODO: la seule fonction qui appelle cette fonction ne semble même pas utilisée. Voir pour supprimer.
		if ( $deployment_item instanceof TrackableItem ) {
			return $deployment_item->getRawMetadataFields()[ $field_name ] ?? null;
		}

		return $deployment_item['fields'][ $this->getTrackingMetaDataTableName( false ) . '.' . $field_name ] ?? null;
	}

	/**
	 * Gets the tracking metadata by local ID. Composite local IDs are supported.
	 *
	 * @param mixed $local_id The item local ID. It must contain data to identify the item such as a primary key.
	 *     Composite local IDs are supported.
	 *
	 * @return array|null The tracking metadata, or null if the item is not trackable.
	 *
	 * @throws WpDeployException If the tracker ID is not set.
	 */
	protected function getItemTrackingMetadata( $local_id ): ?array {
		if ( empty( $local_id ) ) {
			return null;
		}

		$wpdb = Database::getTrackingDatabaseHandle();

		$sql         = 'SELECT * FROM %i WHERE `local_id` = %s;';
		$arguments   = array( $this->getTrackingMetaDataTableName() );
		$arguments[] = $local_id;

		$row = $wpdb->get_row( $wpdb->prepare( $sql, $arguments ), ARRAY_A );

		return ! empty( $row ) ? $row : null;
	}

	/**
	 * Gets the item UUID by local ID. Composite local IDs are supported.
	 *
	 * @param mixed $local_id The local ID of the item to get. Composite local IDs are supported.
	 * @param bool  $with_tracker_prefix Set to true to prepend the tracker prefix. Default: false.
	 *
	 * @return string|null The UUID of the item, or null if the item is not tracked.
	 *
	 * @throws WpDeployException Could not get the item tracking metadata.
	 */
	public function getItemUuidByLocalId( $local_id, bool $with_tracker_prefix = false ): ?string {
		if ( empty( $local_id ) ) {
			return null;
		}

		$metadata = $this->getItemTrackingMetadata( $local_id );

		if ( empty( $metadata['uuid'] ) ) {
			return null;
		}

		if ( $with_tracker_prefix ) {
			return $this->getTrackerId() . '.' . $metadata['uuid'];
		}

		return $metadata['uuid'];
	}

	/**
	 * Gets the local ID definition.
	 *
	 * @return array<string,mixed> The local ID definition. See the class property for the structure.
	 *
	 * @throws WpDeployException If the local ID definition is not set or incomplete.
	 */
	protected function getLocalIdFieldDefinition(): array {
		if ( empty( $this->local_id_definition['name'] ) || empty( $this->local_id_definition['type'] ) ) {
			throw new WpDeployException( 'The local ID field definition must be set before using it.' );
		}

		return $this->local_id_definition;
	}

	/**
	 * Gets the local ID from the given UUID.
	 *
	 * @param string $uuid The UUID of the trackable item. It accepts two formats:
	 *   - The UUID itself.
	 *   - The UUID prefixed with the tracker ID, separated by a point.
	 *
	 * @return mixed|null The local ID of the trackable item, or null if not found.
	 *
	 * @throws WpDeployException Cannot retrieve the ID.
	 */
	public function getLocalIdFromUuid( string $uuid ) {
		$uuid_strip = $this->stripReferencePrefixFromUuid( $uuid );

		// TODO: we should cache the result for speed.
		$wpdb = Database::getTrackingDatabaseHandle();
		$sql  = $wpdb->prepare(
			'SELECT `local_id` FROM %i WHERE `uuid` = %s;',
			$this->getTrackingMetaDataTableName(),
			$uuid_strip
		);

		// Type hint to mixed|null because we don't assume the local ID to be an int nor a string.
		// For autoincremented local IDs, it's an int. For composite local IDs, it's a string.
		/** @var mixed|null $local_id */
		$local_id = $wpdb->get_var( $sql );

		if ( empty( $local_id ) ) {
			return $local_id;
		}

		if ( $this->usesCompositeLocalId() ) {
			// Return the local ID as-is, in the format of the composite local ID (PHP serialized).
			return $local_id;
		}

		if ( ( 'text' !== $this->getLocalIdFieldDefinition()['type'] ) && filter_var( $local_id, FILTER_VALIDATE_INT ) ) {
			// Convert to int if it's an integer; it's probably a value from an autoincremented column.
			$local_id = (int) $local_id;
		}

		// It may return null if invalid.
		return $local_id;
	}

	/**
	 * Gets the field name of the local ID. It may return the name of a composite local ID field.
	 *
	 * @return string The name of the local ID field name. For composite local IDs, it's the JSON serialized format
	 *     of an array containing the field names.
	 *
	 * @throws WpDeployException If the local ID field name is not set.
	 */
	public function getLocalIdFieldName(): string {
		return $this->getLocalIdFieldDefinition()['name'];
	}

	/**
	 * Gets the local IDs of the items to track.
	 *
	 * @return array The IDs of the items to track.
	 *
	 * @throws WpDeployException If the local IDs could not be retrieved.
	 */
	protected function getLocalIdsOfItemsToTrack(): array {
		$wpdb = Database::getTrackingDatabaseHandle();

		if ( ! $this->usesCompositeLocalId() ) {
			$sql = $wpdb->prepare(
				'SELECT %i FROM %i;',
				$this->getLocalIdFieldName(),
				$this->getDataTableName()
			);
		} else {
			/**
			 * Build the query to get the composite local IDs of the items to track.
			 *
			 * The composite local IDs being PHP serialized arrays, we need to build them.
			 */
			$query     = 'SELECT ';
			$arguments = array();

			// This will append the local ID and arguments.
			$this->buildCompositeLocalIdValuesQuery( $query, $arguments );

			$query .= ' FROM %i;';

			$arguments[] = $this->getDataTableName();

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

		$result = $wpdb->get_col( $sql );

		return ! empty( $result ) ? $result : array();
	}

	/**
	 * Gets the name of the plugin this tracker belongs to.
	 *
	 * @return string The name of the plugin this tracker belongs to. May be empty for core trackers.
	 */
	public function getPlugin(): string {
		return $this->plugin;
	}

	/**
	 * Gets the primary tracker this tracker is subordinated to, if any.
	 *
	 * @return ItemTrackerBase|null The primary tracker this tracker is subordinated to, if any.
	 */
	public function getPrimaryTracker(): ?ItemTrackerBase {
		if ( empty( $this->subordination['primary_tracker_id'] ) ) {
			return null;
		}

		return $this->tracking_manager->getItemTrackerByTrackerId( $this->subordination['primary_tracker_id'] );
	}

	/**
	 * Gets the config of the screen label formatter for this tracker items.
	 *
	 * @return array<string,mixed> The config of the screen label formatter for this tracker items.
	 */
	public function getScreenLabelFormatterConfig(): array {
		return $this->screen_label_formatter_config;
	}

	/**
	 * Gets the trackable item registry for this tracker.
	 *
	 * @return TrackableItemCollection The trackable item registry.
	 *
	 * @throws WpDeployException If the trackable item registry could not be retrieved.
	 */
	protected function getTrackableItemRegistry(): TrackableItemCollection {
		if ( empty( $this->trackable_item_registry ) ) {
			try {
				/** @var TrackableItemCollection $trackable_item_registry */
				$trackable_item_registry = $this->getComponentByClassName( TrackableItemCollection::class, true );
			} catch ( UnmetDependencyException $e ) {
				throw new WpDeployException( 'Could not get the trackable item registry.', 0, $e );
			}

			$trackable_item_registry->setItemTracker( $this );
			$this->trackable_item_registry = $trackable_item_registry;
		}

		return $this->trackable_item_registry;
	}

	/**
	 * Gets the tracker ID.
	 *
	 * This is also used to identify the type of the tracked item.
	 * Also, please use not too long IDs, as they will be used as a part of table names.
	 *
	 * @return string The tracker id.
	 *
	 * @throws WpDeployException The tracker ID must be set before using it.
	 */
	public function getTrackerId(): string {
		if ( empty( $this->tracker_id ) ) {
			throw new WpDeployException( 'The tracker ID must be set before using it.' );
		}

		return $this->tracker_id;
	}

	/**
	 * Gets the tracking metadata table name.
	 *
	 * @param bool $with_prefix Whether to include the database prefix.
	 *
	 * @return string The tracking metadata table name. It will be prefixed if $with_prefix is true.
	 *                The table name, including the prefix (even if $with_prefix is false),
	 *                will be truncated to 64 characters.
	 *
	 * @throws WpDeployException Failed to get the tracking metadata table name.
	 *                           The tracker ID must be set before using it.
	 */
	public function getTrackingMetaDataTableName( bool $with_prefix = true ): string {
		return Database::getHashedTableName( $this->getTrackerId(), $with_prefix );
	}

	/**
	 * The UUID source fields, if any.
	 *
	 * @param bool $include_primary_tracker_field_id Whether to include the primary tracker field ID, if any.
	 *   Defaults to true.
	 *
	 * @return string[]|null The UUID source fields as array, or null if unset or irrelevant.
	 */
	public function getUuidSourceFields( bool $include_primary_tracker_field_id = true ): ?array {
		if ( $include_primary_tracker_field_id && ! empty( $this->subordination['primary_item_id_field'] ) ) {
			// By default, we include the primary tracker field ID, if any.
			// Indeed, this is possible because we validated that the trackers are not circular dependent.
			// See TrackerConfigProviderManager::validateTrackerSubordination().
			// Note: a primary tracker cannot be a composite local ID based tracker.
			$fields = $this->uuid_source_fields;

			if ( ! in_array( $this->subordination['primary_item_id_field'], $this->uuid_source_fields, true ) ) {
				$fields[] = $this->subordination['primary_item_id_field'];
			}

			return $fields;
		}

		return $this->uuid_source_fields;
	}

	/**
	 * Imports an item into the database.
	 *
	 * @param string        $item_full_uuid The full UUID of the item to import.
	 * @param array         $incoming_item The item payload from the import file.
	 * @param array         $items_to_import The complete list of items to import.
	 *   This is used to retrieve the dependencies of the item to import.
	 * @param string[]      $import_result The result of a previous import.
	 * @param array<string> $already_processed The list of items full UUIDs already processed during the import.
	 * @param bool          $force_import Whether to force the import of the item.
	 * @param callable|null $progress_callback The callback to call on export/import events.
	 *
	 * @return array<string,string> The result of the import, keyed by the item full UUID.
	 *   If any listed item was not imported or skipped, none of the items will be imported.
	 *   This list is useful to show the user the issues that occurred during the import,
	 *   and to allow them to fix the issues and retry the import.
	 */
	public function importItem( string $item_full_uuid, array $incoming_item, array &$items_to_import, array &$import_result, ?array &$already_processed = array(), bool $force_import = false, ?callable $progress_callback = null ): array {
		$already_processed = $already_processed ?? array();

		if ( in_array( $item_full_uuid, $already_processed, true ) ) {
			// Already processed.
			return $import_result;
		}

		try {
			// Start the transaction.
			// Needed to easily revert the import if something goes wrong.
			$wpdb = Database::getTrackingDatabaseHandle();

			// Retry system.
			$retry_count = 0;
			$max_retries = 3;

			$already_processed_before_import = $already_processed;

			while ( true ) {
				try {
					$wpdb->query( 'START TRANSACTION;/*dsc:ignore_tracking*/' );

					$this->importItem_real( $item_full_uuid, $incoming_item, $items_to_import, $import_result, $already_processed, $force_import, $progress_callback );

					// The import was successful.
					break;
				} catch ( Throwable $throwable ) {
					// The import failed. It is maybe due to a DB deadlock
					// or a temporary issue.
					if ( $retry_count >= $max_retries ) {
						// Retried too many times. Nothing more to do.
						$progress_callback( array( 'message_line' => sprintf( _dgettext( 'deployer-core', 'The import of %1$s failed %2$d times.' ), $item_full_uuid, $retry_count ) ) );

						throw $throwable;
					}

					// We can still retry.
					++$retry_count;

					$progress_callback( array( 'message_line' => sprintf( _dgettext( 'deployer-core', 'The import of %1$s failed. Retrying the import chain for this item (%2$d)...' ), $item_full_uuid, $retry_count ) ) );

					// Rollback the transaction to release the locks that may
					// have caused a DB deadlock.
					$wpdb->query( 'ROLLBACK;/*dsc:ignore_tracking*/' );

					// Restore the already processed list to be able to
					// redo the entire import chain like the first try.
					$already_processed = $already_processed_before_import;

					// The delay in ms.
					usleep( 1000 * pow( $retry_count + 1, 6 ) );

					continue;
				}
			}
		} catch ( Throwable $exception ) {
			// Retrieve the exception for the "finally" block.
		} finally {
			if ( isset( $exception ) ) {
				// The item itself was not imported.
				// Rollback the transaction.
				$wpdb->query( 'ROLLBACK;/*dsc:ignore_tracking*/' );

				return $import_result;
			}

			// For each required item we attempted to import, check if it was successful.
			foreach ( $import_result as $result ) {
				if ( 'import' === $result || 'skip' === $result ) {
					continue;
				}

				// The required item was not imported (usually a conflict or an unexpected error).
				// Rollback the transaction.
				$wpdb->query( 'ROLLBACK;/*dsc:ignore_tracking*/' );

				return $import_result;
			}

			// Commit the transaction.
			$wpdb->query( 'COMMIT;/*dsc:ignore_tracking*/' );

			return $import_result;
		}
	}

	/**
	 * Schedules the given item IDs for tracking.
	 *
	 * Useful when installing the tracking, or reindex missing IDs.
	 *
	 * @param array $ids_of_items_to_track Local IDs of items to track.
	 *
	 * @throws WpDeployException Invalid ItemTrackerBase config.
	 */
	private function trackItems( array $ids_of_items_to_track ): void {
		$operation_total_count = count( $ids_of_items_to_track );

		if ( empty( $operation_total_count ) ) {
			// No items to track.
			return;
		}

		// This will inform any caller that the tracking queue has been filled with items.
		$requires_tracking        = $options['requires_tracking'] ?? new \stdClass();
		$requires_tracking->value = true;

		if ( ! $this->usesCompositeLocalId() ) {
			$ids_of_items_to_track_chunk_size = 100;
		} else {
			// Reduce the number of items per chunk the more composite local IDs are used.
			// This is a way to prevent action scheduler from refusing to schedule
			// due to a too long args value (8000 characters max).
			$composite_field_names = $this->getCompositeLocalIdFieldNames();

			$ids_of_items_to_track_chunk_size = ceil( 100 / max( 1, count( $composite_field_names ) ) );
		}

		while ( ! empty( $ids_of_items_to_track ) ) {
			$ids_of_items_to_track_chunk = array_splice( $ids_of_items_to_track, 0, $ids_of_items_to_track_chunk_size );

			// Track the items that are not yet tracked (no_update).
			$this->track_scheduler->trackItems(
				$this->getTrackerId(),
				$ids_of_items_to_track_chunk,
				array(
					'no_update' => true,
				)
			);
		}
	}

	/**
	 * Imports an item into the database.
	 *
	 * @param string               $item_full_uuid The full UUID of the item to import.
	 * @param array                $item The item payload from the import file.
	 * @param array                $items_to_import The complete list of items to import.
	 *     This is used to retrieve the dependencies of the item to import.
	 *     Passed by reference to prevent performance issues when importing a large number of items.
	 *     It must not be modified by this method.
	 * @param array<string,string> $import_result The result of the import process.
	 * @param array<string>        $already_processed The items full UUIDs that have already been processed.
	 *     Use this to prevent importing the same item multiple times.
	 * @param bool                 $force_import Whether to force the import of the item.
	 *
	 * @throws WpDeployException Failed to import the item.
	 */
	private function importItem_real( string $item_full_uuid, array $item, array &$items_to_import, array &$import_result, ?array &$already_processed = array(), bool $force_import = false, ?callable $progress_callback = null ): void {
		$already_processed = $already_processed ?? array();

		if ( in_array( $item_full_uuid, $already_processed, true ) ) {
			// Already processed.
			return;
		}

		// Mark the item as processed to prevent reprocess.
		$already_processed[] = $item_full_uuid;

		$progress_callback = $progress_callback ?? static function ( array $progress ) {
		};

		$progress_callback(
			array(
				'processed_item' => $item_full_uuid,
				'tick'           => 1,
			)
		);

		try {
			// Note: we do not check if the item is excluded from the import,
			// because it should not be in the list of items to import in the first place.
			$importability_result = $this->checkImportability( $item_full_uuid, $item );

			if ( $importability_result & Importability::TRACKER_CONFIG_MISMATCH ) {
				// This case happens when we tried to import an item directly
				// without passing through the main deployment process with a complete bundle.
				// Indeed, it is possible to import an item directly using a single item payload,
				// but in this case, the only info we have is the config hash of the tracker
				// that was used to export the item.
				// If we have passed through the main deployment process, the importer service
				// would have blocked the import before we reach this point.
				// Note: even if the user asked to force the import,
				// we do not want to import the item if the tracker config does not match,
				// because it could be terribly wrong to import an item with a different tracker config.
				$status = _dgettext( 'deployer-core', 'The item has been exported using a different tracker configuration' );

				return;
			}

			if ( ! $force_import ) {
				if ( ! ( $importability_result & Importability::HAS_CHANGES_TO_IMPORT ) ) {
					$status = 'skip';

					return;
				}

				if ( ! ( $importability_result & Importability::CAN_IMPORT ) && ! ( $importability_result & Importability::MERGE_IS_VALID ) ) {
					$status = _dgettext( 'deployer-core', 'The item has changes but cannot be imported.' );

					// This will trigger the "finally" block.
					return;
				}
			}

			$status = 'import';

			$tmd_table_name           = null;
			$get_tmd_table_field_name = function ( string $field_name ) use ( &$tmd_table_name ) {
				$tmd_table_name = $tmd_table_name ?? $this->getTrackingMetaDataTableName( false );

				return $tmd_table_name . '.' . $field_name;
			};

			// From this point, either the item does not exist in database, or it is outdated.
			if ( $this->tracking_manager->isProductionEnvironment() ) {
				// Update the prod_sync_date right now.
				// This way, we do not have to track the item again after the import
				// to update the prod_sync_date, and this will preserve the last_modification_date
				// sent by the incoming item.
				$item['fields'][ $get_tmd_table_field_name( 'prod_sync_date' ) ] = $item['fields'][ $get_tmd_table_field_name( 'last_modification_date' ) ];
			}

			if ( $importability_result & Importability::MERGE_IS_VALID ) {
				// This is a merge. We use the merge date as the new local last_modification_date.
				// This will ensure that the next import using the same merge date will result
				// in a "skip" status.
				$item['fields'][ $get_tmd_table_field_name( 'last_modification_date' ) ] = $item['fields'][ $get_tmd_table_field_name( 'merge_date' ) ];
			}

			// Import the item without resolving the dependencies yet.
			$short_uuid     = $this->stripReferencePrefixFromUuid( $item_full_uuid );
			$trackable_item = $this->presaveTrackableItem( $short_uuid, $item['fields'], $progress_callback );

			// Import the items that this item requires to work.
			$required_items = $item['depends_on'];

			foreach ( $required_items as $required_item ) {
				try {
					// Because the current item is already in the storage,
					// we can safely recurse on the dependent items which can in turn
					// reference this current item if needed (relevant only for autoincrement local ID based trackers).
					// This does not mean that importItem() cannot fail, but it will not fail
					// because of a circular dependency.
					$dep_item = $items_to_import[ $required_item ];

					$dep_tracker = $this->tracking_manager->getItemTrackerByDeploymentItem( $dep_item );

					if ( empty( $dep_tracker ) ) {
						throw new WpDeployException( sprintf( _dgettext( 'deployer-core', 'The tracker %s is required for import but is not available.' ), $dep_item['tracker'] ) );
					}

					$dep_tracker->importItem_real( $required_item, $items_to_import[ $required_item ], $items_to_import, $import_result, $already_processed, $force_import, $progress_callback );
				} catch ( WpDeployException $exception ) {
					// The required item could not be imported.
					// But don't make it fail here so that we can track all the items that failed to import.
					unset( $exception );
					continue;
				}
			}

			// The dependencies have been created. Now, we can tell this item to set the references to its dependencies,
			// as well as the necessary processes which can only occur at the end of the import process.
			$this->saveTrackableItem( $trackable_item, ItemValueHandlerBase::OPERATION__IMPORT__FINALIZE, $progress_callback );
		} catch ( WpDeployException $exception ) {
			// The item could not be imported.
			$status = 'Error: ' . $exception->getMessage();
		} finally {
			$final_status = $status ?? _dgettext( 'deployer-core', 'Error: Cannot determine importability of the item.' );

			$import_result[ $item_full_uuid ] = $final_status;

			switch ( $final_status ) {
				case 'import':
					$final_message = 'Imported.';
					break;

				case 'skip':
					$final_message = 'Skipped.';
					break;

				default:
					$final_message = $final_status;
					break;
			}

			$progress_callback(
				array(
					'message' => "[$item_full_uuid]: $final_message",
				)
			);

			if ( isset( $exception ) ) {
				// Rethrow the exception so that the caller can handle it.
				throw $exception;
			}
		}
	}

	/**
	 * Initializes this item tracker instance.
	 *
	 * @param array $definition The definition of the item tracker. Has the following keys:
	 *   - class: The class name of this item tracker. Used only by the tracking manager.
	 *   - field_definitions_overrides: Array of field definitions overrides, keyed by field
	 *     name.
	 *   - local_id_field: Array of local ID field name and type.
	 *   - tracker_id: The tracker ID. Usually the source table name (without the DB prefix).
	 *
	 * @throws WpDeployException If the item tracker could not be initialized.
	 */
	public function init( array $definition ): void {
		// The original definition is used to calculate the hash.
		$this->raw_tracker_config  = $definition;
		$this->tracker_config_hash = TrackerConfigUtils::buildTrackerConfigHash( $this->raw_tracker_config );

		// Unload the trackable items to avoid outdated data if this tracker has already been used before.
		$this->unloadTrackableItems();

		// Normalize the definition.
		$item_tracker_definition_structure = array(
			'class'                       => '',
			'excluded'                    => false,
			'field_definitions_overrides' => array(),
			'local_id_field'              => array(),
			'plugin'                      => '',
			'screen_label_formatter'      => array(),
			'subordination'               => array(),
			'tracker_id'                  => '',
			'track_date_initializer'      => 'base',
			'uuid_random_generation'      => null,
			'uuid_source_fields'          => null,
		);

		/**
		 * The default field type is "text". It supports all types of data, but it is slower than numerical columns
		 * because it is loaded from the disk, not the memory.
		 */
		$local_id_definition_structure = array(
			'name' => '',
			'type' => 'text',
		);

		$definition = array_merge( $item_tracker_definition_structure, $definition );

		// Normalize the local ID definition.
		$definition['local_id_field'] = array_merge( $local_id_definition_structure, $definition['local_id_field'] ?? array() );

		$decoded_local_id_name = json_decode( $definition['local_id_field']['name'], true );

		if ( is_array( $decoded_local_id_name ) ) {
			// Sort the local ID fields by name to ensure that the order is always the same.
			ksort( $decoded_local_id_name );

			$definition['local_id_field']['name'] = json_encode( $decoded_local_id_name );
		}

		// TODO: make this configurable.
		switch ( $definition['tracker_id'] ) {
			case 'posts':
				$definition['track_date_initializer'] = 'posts';
				break;

			case 'postmeta':
				$definition['track_date_initializer'] = 'postmeta';
				break;
		}

		list (
			'excluded' => $this->excluded,
			'field_definitions_overrides' => $this->field_definitions_overrides,
			'local_id_field' => $this->local_id_definition,
			'plugin' => $this->plugin,
			'screen_label_formatter' => $this->screen_label_formatter_config,
			'subordination' => $this->subordination,
			'tracker_id' => $this->tracker_id,
			'uuid_random_generation' => $this->uuid_random_generation,
			'uuid_source_fields' => $this->uuid_source_fields,
			) = $definition;

		if ( empty( $this->local_id_definition ) ) {
			throw new WpDeployException( 'The local ID field definition is not defined.' );
		}

		if ( empty( $this->tracker_id ) ) {
			throw new WpDeployException( 'The tracker ID is not defined.' );
		}

		// This will get the default initializer if the ID does not exist.
		$this->track_date_initializer = $this->getComponentByClassName(
			$this->track_date_initializer_manager->getInitializerClassNameById( $definition['track_date_initializer'] ),
			true
		);

		$this->tracker_config = $definition;
	}

	/**
	 * Installs the tracking for the managed item type (e.g. create a uuid column, create a tracking table, etc.).
	 *
	 * This can be called to update the tracking when the table is updated (e.g. new columns).
	 *
	 * @param array|null $options Options for the installation. Available options:
	 *     - "requires_tracking" (object): An object that can tell callers if items will be processed.
	 *       Use ->value (bool) to get the value.
	 *
	 * @throws WpDeployException The tracking installation failed.
	 */
	public function installTracking( ?array $options = array() ): void {
		// Start by dropping any trigger that already exist.
		// This is needed in case any column has changed.
		$this->dropTrackingTriggers();

		$tracking_metadata_table_name = $this->getTrackingMetaDataTableName();

		if ( Database::tableExists( $tracking_metadata_table_name ) ) {
			// The tracking table already exists. No need to install it again.
			// Simply recreate the triggers.
			$this->createTrackingTriggers();

			return;
		}

		// Note: last_modification_date is nullable to ease the import process.
		// datetime(6) is used to store the microseconds.
		$sql       = <<<'SQL'
CREATE TABLE %i (
	`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
	`uuid` varchar(36) NOT NULL,
	`last_modification_date` datetime(6),
	`prod_sync_date` datetime(6),
	`merge_date` datetime(6),

SQL;
		$arguments = array( $tracking_metadata_table_name );

		$allowed_data_attributes = array(
			'unsigned',
			'signed',
			'zerofill',
		);

		$field_name = $this->getLocalIdFieldDefinition()['name'];
		$field_type = $this->getLocalIdFieldDefinition()['type'] ?? 'text';

		if ( empty( $field_type ) || ! is_string( $field_type ) ) {
			throw new WpDeployException( sprintf( 'The local ID field type is not defined for field "%s".', $field_name ) );
		}

		// Validate the data type.
		// Split the data type. The data type is programmatically generated, so it should be valid.
		$data_type_split = explode( ' ', $field_type, 2 );

		// Data type without attributes.
		$data_type = $data_type_split[0];

		if ( count( $data_type_split ) > 1 ) {
			// The data type contains attribute(s).
			$attributes = explode( ' ', $data_type_split[1] );

			// Perform a very simple check of attributes.
			foreach ( $attributes as $attribute ) {
				if ( ! in_array( strtolower( $attribute ), $allowed_data_attributes, true ) ) {
					throw new WpDeployException( sprintf( 'Invalid or disallowed local ID field type "%s".', $field_type ) );
				}
			}
		}

		// Simple pattern check.
		if ( ! preg_match(
			<<<PATTERN
/^[a-z]+
(?:
  \(
    (?P<precision>\d+)
    (?:,(?P<scale>\d+))?
  \)
)?
$/ix
PATTERN,
			$data_type
		) ) {
			throw new WpDeployException( sprintf( 'Invalid local ID field type "%s".', $field_type ) );
		}

		// Note: the spaces are important, because dbDelta needs them.
		$sql .= <<<SQL
    `local_id` $field_type,
	PRIMARY KEY `uuid` (`uuid`),
	UNIQUE KEY `id` (`id`)
);
SQL;

		Database::doDbDelta(
			Database::getTrackingDatabaseHandle()->prepare( $sql, $arguments )
		);

		// Gets the full list of IDs of items to track.
		$ids_of_items_to_track = $this->getLocalIdsOfItemsToTrack();

		// The changes may be tracked now. Create the triggers.
		$this->createTrackingTriggers();

		$this->trackItems( $ids_of_items_to_track );
	}

	/**
	 * Checks if the first given item is subordinated to the second given item.
	 *
	 * @param TrackableItem|array<string,mixed> $subordinate_item The TrackableItem or the deployment item data.
	 * @param TrackableItem|array<string,mixed> $primary_item The TrackableItem or the deployment item data.
	 *
	 * @return bool True if the first given item is subordinated to the second given item, false otherwise.
	 *
	 * @throws WpDeployException Config error on the item tracker, or the given items are not valid.
	 */
	public function isDeploymentItemSubordinatedTo( $subordinate_item, $primary_item ): bool {
		if ( empty( $this->subordination['primary_tracker_id'] ) ) {
			// This tracker is not subordinated to any other tracker.
			return false;
		}

		if ( empty( $this->subordination['primary_item_id_field'] ) ) {
			throw new WpDeployException( 'The primary item ID field is not defined in the subordination config of: ' . $this->getTrackerId() . '.' );
		}

		if ( $subordinate_item instanceof TrackableItem && ! $primary_item instanceof TrackableItem ) {
			throw new WpDeployException( 'The subordinate item is a TrackableItem, but the primary item is not.' );
		}

		if ( is_array( $subordinate_item ) && ! is_array( $primary_item ) ) {
			throw new WpDeployException( 'The subordinate item is an array, but the primary item is not.' );
		}

		if ( ! $subordinate_item instanceof TrackableItem && ! is_array( $subordinate_item ) ) {
			throw new WpDeployException( 'The subordinate item is neither a TrackableItem nor an array.' );
		}

		if ( ! $primary_item instanceof TrackableItem && ! is_array( $primary_item ) ) {
			throw new WpDeployException( 'The primary item is neither a TrackableItem nor an array.' );
		}

		// Subordination checks are only possible on active trackers.
		$subordinate_item_tracker = $this->tracking_manager->getItemTrackerByDeploymentItem( $subordinate_item );

		if ( empty( $subordinate_item_tracker ) || ( $this->getTrackerId() !== $subordinate_item_tracker->getTrackerId() ) ) {
			throw new WpDeployException( 'The given subordinate item is not managed by this tracker.' );
		}

		// Get the full UUID of the item referenced by the primary_item_id_field.
		$field_name = $this->subordination['primary_item_id_field'];

		if ( $subordinate_item instanceof TrackableItem ) {
			$subordinated_to = $subordinate_item->getRawDataFields()[ $field_name ] ?? null;

			if ( empty( $subordinated_to ) ) {
				// The subordinate item does not reference any primary item.
				return false;
			}

			// Compare them as strings to ensure that the comparison is not type-dependent.
			return (string) $subordinated_to === (string) $primary_item->getLocalId();
		}

		$primary_item_uuid = $this->tracking_manager->getUuidFromDeploymentItem( $primary_item );

		if ( empty( $primary_item_uuid ) ) {
			throw new WpDeployException( 'Could not retrieve the full UUID of the primary item when checking subordination.' );
		}

		$subordinated_to = $subordinate_item['fields'][ $this->getTrackerId() . '.' . $field_name ] ?? null;

		return $subordinated_to === $primary_item_uuid;
	}

	/**
	 * Tells if the tracker is excluded.
	 *
	 * @param bool $recursive_check True to check if the tracker is excluded recursively, false otherwise.
	 *   For example, if this tracker is subordinated to a tracker that is excluded, then this tracker is also excluded.
	 *
	 * @return bool True if the tracker is excluded, false otherwise.
	 */
	public function isExcluded( bool $recursive_check = true ): bool {
		if ( ! $recursive_check ) {
			return $this->excluded;
		}

		// Get the tracker that this tracker is subordinated to, if any.
		$primary_tracker_id = $this->subordination['primary_tracker_id'] ?? null;

		if ( empty( $primary_tracker_id ) ) {
			// This tracker is not subordinated to any other tracker.
			return $this->excluded;
		}

		// Get the tracker instance and check if it is excluded.
		$primary_tracker = $this->tracking_manager->getItemTrackerByTrackerId( $primary_tracker_id );

		if ( empty( $primary_tracker ) ) {
			// The primary tracker is not active.
			// This should not happen, so we assume that this tracker must be excluded.
			// A log could be added here to notify the user about this.
			return true;
		}

		return $primary_tracker->isExcluded() || $this->excluded;
	}

	/**
	 * Gets the trackable item by its UUID.
	 *
	 * @param string              $uuid The UUID of the tracked item. See stripReferencePrefixFromUuid() for the
	 *     accepted values.
	 * @param array<string,mixed> $options The options to use for loading the item.
	 *   See TrackableItemCollection::loadTrackableItemsByUuid() for the accepted options.
	 *
	 * @return TrackableItem|null The trackable item, or null if the item is not tracked.
	 *
	 * @throws WpDeployException The trackable item could not be loaded due to a technical error.
	 */
	public function loadTrackableItem( string $uuid, array $options = array() ): ?TrackableItem {
		$uuid = $this->stripReferencePrefixFromUuid( $uuid );

		$items = $this->loadTrackableItems( array( $uuid ), $options );

		return ! empty( $items ) ? reset( $items ) : null;
	}

	/**
	 * Loads the trackable items associated to this tracker for export.
	 *
	 * @param array|null          $uuids The full item UUIDs to load. If null, all eligible items will be loaded.
	 * @param array<string,mixed> $options The options to use for loading the items.
	 *   See TrackableItemCollection::loadTrackableItemsByUuid() for the supported options.
	 *
	 * @return array<TrackableItem> The trackable items.
	 *
	 * @throws WpDeployException The tracked items could not be loaded due to a technical error.
	 */
	public function loadTrackableItems( ?array $uuids = null, array $options = array() ): array {
		return $this->getTrackableItemRegistry()->loadTrackableItemsByUuid( $uuids, $options );
	}

	/**
	 * Call this to ensure the given item exists in the storage.
	 *
	 * Dev note: Use this only for imports, as this requires an UUID.
	 *
	 * This will create an entry in the data table and the metadata table if it doesn't exist.
	 *
	 * @param string        $uuid The expected short UUID of the item.
	 * @param array         $fields Fields to set on the item. The values are required to come from the import files;
	 *   it does not make sense to set the values from the database, as we're trying to ensure the item exists in the
	 *   storage.
	 * @param callable|null $progress_callback The callable called on progress events.
	 *
	 * @return TrackableItem The trackable item of the given UUID.
	 *
	 * @throws WpDeployException The given UUID is empty or the trackable item could not be created.
	 */
	public function presaveTrackableItem( string $uuid, array $fields, ?callable $progress_callback = null ): TrackableItem {
		if ( empty( $uuid ) ) {
			throw new WpDeployException( 'The given UUID cannot be empty in presaveTrackableItem().' );
		}

		// Retrieve the trackable item from the database, or create a new one if it doesn't exist.
		$trackable_items = $this->loadTrackableItems( array( $uuid ) ) ?? null;
		$trackable_item  = ! empty( $trackable_items ) ? reset( $trackable_items ) : null;

		if ( ! $trackable_item instanceof TrackableItem ) {
			// The item does not exist in the database yet. Create a new one.
			$trackable_item = $this->getTrackableItemRegistry()->createEmptyTrackableItem( $uuid );
		} elseif ( count( $trackable_items ) > 1 ) {
			throw new WpDeployException( 'The given UUID is not unique in the database. Tracker: ' . $this->getTrackerId() . ', UUID: ' . $uuid );
		}

		// This will take care of preparing the values on the TrackableItem instance.
		// It doesn't make sense to ensure an item if the fields come from the database,
		// so we assume the origin is the import file.
		$this->assignTrackableItemValues( $trackable_item, $fields, ItemValueHandlerBase::ORIGIN__IMPORT_FILE, array( 'include_local_id' => true ) );

		if ( ! $this->usesCompositeLocalId() ) {
			// Save the values in the database. This will generate the local ID if the item does not exist yet.
			// Composite local ID based tracker items should never be presaved, because their local ID may be based
			// on the values of the item, which may be reference values that do not exist yet.
			// We wouldn't be able to add this item in the tmd table.
			// That's why we don't save the item here, but we still prepare it for the next steps.
			$this->saveTrackableItem( $trackable_item, ItemValueHandlerBase::OPERATION__IMPORT__PRESAVE, $progress_callback );
		}

		return $trackable_item;
	}

	/**
	 * Process the dependencies of all the loaded trackable items.
	 *
	 * @throws WpDeployException Error on the dependencies or the registry.
	 *
	 * @deprecated Not used anymore. Dependency processing has been deferred to the manager.
	 */
	public function processLoadedTrackableItemsDependencies(): void {
		foreach ( $this->getTrackableItemRegistry()->getCollectedTrackableItems() as $trackable_item ) {
			try {
				$trackable_item->processDependencies();
			} catch ( WpDeployException $e ) {
				$trackable_item->setDependencyValidityStatus( false );
			}
		}
	}

	/**
	 * Resets (truncates) the tracking info.
	 *
	 * @throws Throwable Unmanageable error.
	 * @throws WpDeployException Tracker error.
	 */
	public function resetTracking(): void {
		$db = Database::getTrackingDatabaseHandle();

		$db->query( $db->prepare( 'TRUNCATE %i;', array( $this->getTrackingMetaDataTableName() ) ) );

		// Tell the UUID service to clean up the tracking for this tracker.
		$this->uuid->resetTracking( $this );
	}

	/**
	 * Call this to retrack (reindex) all non-tracked items.
	 *
	 * The tracking tables must be already available.
	 *
	 * @throws WpDeployException ItemTrackerBase invalid config, or not properly initialized.
	 */
	public function retrackItems(): void {
		// Get the full list of IDs of items to track.
		$ids_of_items_to_track = $this->getLocalIdsOfItemsToTrack();

		// trackItems() will not track items which are already tracked.
		$this->trackItems( $ids_of_items_to_track );
	}

	/**
	 * Executes a query, and retries it once with lock on the given table name.
	 *
	 * @param string $table_name_to_lock The table name to lock if needed.
	 * @param string $sql The SQL statement to execute.
	 *
	 * @return bool|int|\mysqli_result|null The result of the query.
	 *
	 * @throws \Ealab\Deployer\Exception\WpDeployException On failure.
	 */
	private function retryableSqlWithLock( string $table_name_to_lock, string $sql ) {
		$wpdb = Database::getTrackingDatabaseHandle();

		$result = $wpdb->query( $sql );

		if ( false === $result ) {
			// Retry with a lock on the metadata table,
			// because another plugin may have added a lock
			// to another table, and this would force us to
			// lock our own table.
			$query    = "LOCK TABLES %i WRITE;" . SqlChangesTracker::IGNORE_TRACKING;
			$prepared = $wpdb->prepare( $query, array( $table_name_to_lock ) );
			$result   = $wpdb->query( $prepared );

			if ( false === $result ) {
				// Cannot even lock tables.
				throw new WpDeployException( 'An item could not be tracked.' );
			}

			// Retry.
			$result = $wpdb->query( $sql );

			if ( false === $result ) {
				throw new WpDeployException( 'An item could not be tracked.' );
			}
		}

		return $result;
	}

	/**
	 * Saves the given trackable item in the database.
	 *
	 * Do not use this to presave items managed by composite local ID based trackers.
	 *
	 * @param TrackableItem $trackable_item The trackable item to save.
	 * @param int           $import_operation The import operation to perform. See ItemValueHandlerBase::OPERATION__*.
	 * @param callable|null $progress_callback The callable called on progress events. See Importer for examples of use.
	 *
	 * @throws WpDeployException The trackable item could not be saved due to a technical error.
	 */
	private function saveTrackableItem( TrackableItem $trackable_item, int $import_operation, ?callable $progress_callback = null ): void {
		$allowed_operations_for_save = array(
			ItemValueHandlerBase::OPERATION__IMPORT__PRESAVE,
			ItemValueHandlerBase::OPERATION__IMPORT__FINALIZE,
		);

		if ( ! in_array( $import_operation, $allowed_operations_for_save, true ) ) {
			// Only IMPORT__* operations are allowed for import.
			throw new WpDeployException( 'Invalid $operation argument given in ItemTrackerBase::saveTrackableItem(): ' . $import_operation . '.' );
		}

		$wpdb = Database::getTrackingDatabaseHandle();

		$field_values    = $trackable_item->getFieldValues( $import_operation, $progress_callback );
		$metadata_fields = array();
		$data_fields     = array();

		$this->separateDataFromMetadata( $field_values, $metadata_fields, $data_fields );

		// Attempt to retrieve the current local ID of the item.
		$usesCompositeLocalId = $this->usesCompositeLocalId();

		if ( ! $usesCompositeLocalId ) {
			// If it's a new item, it does not have yet a local ID (autoincrement ID).
			// It will be generated after the first import.
			$maybe_local_id                          = $trackable_item->getLocalId();
			$maybe_autoincrement_local_id_field_name = $trackable_item->getRegistry()->getItemTracker()->getLocalIdFieldName();
		} else {
			// Composite local ID generation should never fail
			// because their dependencies should have been already imported.
			// Indeed, the composite local ID is generated from the local IDs of the dependencies,
			// and the dependencies are imported before the item that depends on them.
			// Thus, presave of composite local ID based tracker items should never happen
			// because it would fail due to missing dependencies.
			$maybe_local_id = $this->buildItemCompositeLocalId( $trackable_item );
		}

		if ( ! empty( $maybe_local_id ) ) {
			// Include the local ID in the data fields.
			// This will allow to update the item if it already exists.
			if ( ! $usesCompositeLocalId ) {
				$data_fields[ $this->getDataTableName( false ) . '.' . $this->getLocalIdFieldName() ] = $maybe_local_id;
			} else {
				// Break the composite local ID into its parts.
				$local_id_values = $this->getCompositeLocalIdValues( $maybe_local_id );

				foreach ( $local_id_values as $composite_field_name => $local_id_value ) {
					$data_fields[ $this->getDataTableName( false ) . '.' . $composite_field_name ] = $local_id_value;
				}
			}
		}

		// Those values will be set by write_in_db.
		$wpdb_last_error      = '';
		$wpdb_insert_id       = 0;
		$mysql_last_insert_id = null;

		$write_in_db = static function ( string $table_name, array $fields, string $primary_autoincrement_field_name = null ) use ( $wpdb, &$wpdb_insert_id, &$mysql_last_insert_id, &$wpdb_last_error, $usesCompositeLocalId ) {
			$query_to_prepare = 'INSERT INTO %i (';

			$arguments   = array();
			$arguments[] = $table_name;

			$field_names       = array();
			$field_names_args  = array();
			$field_values      = array();
			$field_values_args = array();

			foreach ( $fields as $full_field_name => $value ) {
				$field_names[] = '%i';

				// Split the table name and the field name.
				$parts = explode( '.', $full_field_name, 2 );

				if ( count( $parts ) !== 2 ) {
					throw new WpDeployException( 'Invalid field name: ' . $full_field_name . '.' );
				}

				$field_names_args[] = $parts[1];

				// Send as string and let MySQL recast the value to the correct type.
				$field_values[]      = '%s';
				$field_values_args[] = $value;
			}

			foreach ( $field_names_args as $arg ) {
				$arguments[] = $arg;
			}

			foreach ( $field_values_args as $arg ) {
				$arguments[] = $arg;
			}

			$query_to_prepare .= implode( ',', $field_names ) . ') VALUES (' . implode( ',', $field_values ) . ')';

			// Update on duplicate key.
			$query_to_prepare .= ' ON DUPLICATE KEY UPDATE ';

			$to_implode = array();

			if ( ! empty( $primary_autoincrement_field_name ) ) {
				// Update the local ID field, using the "LAST_INSERT_ID()" command.
				// This is absolutely necessary to get the ID of the row when the
				// item already exists, but is not yet in the tracking table.
				// This is a rare case which happens when the import is done in the
				// same time as another process inserts a row identified by the same key,
				// and this inserted row is not yet tracked.
				// See https://dev.mysql.com/doc/refman/8.4/en/insert-on-duplicate.html.
				$to_implode[] = '%i = LAST_INSERT_ID(%i)';
				$arguments[]  = $primary_autoincrement_field_name;
				$arguments[]  = $primary_autoincrement_field_name;
			}

			foreach ( $fields as $full_field_name => $value ) {
				$to_implode[] = '%i = VALUES(%i)';

				// Split the table name and the field name.
				$parts = explode( '.', $full_field_name, 2 );

				$arguments[] = $parts[1];
				$arguments[] = $parts[1];
			}

			$query_to_prepare .= implode( ',', $to_implode ) . ';';

			// Disable tracking triggers.
			// TODO: disabled, to optimize imports: disable at the start and enable at the end of the import process.
			//  Moved into Importer::importItems(). Remove if confirmed to work.
//			SqlChangesTracker::disableCurrentSessionTracking();

			$query_to_prepare .= '/*dsc:ignore_tracking*/' . "\n";

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

			// Reset the last_insert_id value.
			// We rely on the LAST_INSERT_ID() command to retrieve the
			// row ID for the INSERT ... ON DUPLICATE KEY UPDATE query.
			// Relying on mysqli_insert_id() will return 0 when updating,
			// and we need to know the row ID value for autoincremented-based
			// trackers.
			( ! $usesCompositeLocalId ) && $wpdb->query( 'SELECT LAST_INSERT_ID(0);/*dsc:ignore_tracking*/' );

			try {
				// Retry system.
				$retry_count = 0;
				$max_retries = 2;

				while ( true ) {
					try {
						$query_result = $wpdb->query( $sql );

						if ( false === $query_result ) {
							throw new WpDeployException( 'Error while saving trackable item in data table: ' . $wpdb_last_error );
						}
					} catch ( Throwable $throwable ) {
						// The query failed. It is maybe due to a deadlock or
						// a temporary issue.
						// Dev note: when deadlocks occur, we will retry
						// the entire transaction. See importItem().
						if ( $retry_count >= $max_retries ) {
							// Retried too many times. Nothing more to do.
							throw $throwable;
						}

						// We can still retry.
						++$retry_count;

						// The delay in ms.
						usleep( 1000 * pow( $retry_count + 1, 6 ) );

						continue;
					}

					// The query was successful.
					break;
				}
			} finally {
				// Successful, or retried too many times.
				$wpdb_last_error = $wpdb->last_error;
				$wpdb_insert_id  = $wpdb->insert_id;

				if ( empty( $wpdb_insert_id ) ) {
					// Get the LAST_INSERT_ID() value, which is more reliable than $wpdb->insert_id
					// when doing a ON DUPLICATE KEY UPDATE clause.
					$mysql_last_insert_id = ! $usesCompositeLocalId
						? $wpdb->get_var( 'SELECT LAST_INSERT_ID();/*dsc:ignore_tracking*/' )
						// Last inserted id is not relevant for composite key based trackers.
						: null;
				}

				// Revert the no_tracking var.
				// TODO: disabled, to optimize imports: disable at the start and enable at the end of the import process.
				//  Moved into Importer::importItems(). Remove if confirmed to work.
//				SqlChangesTracker::enableCurrentSessionTracking();
			}

			return $query_result;
		};

		// Save into the data table first in order to get the local ID if the item
		// (relevant only for autoincrement local ID based trackers).
		$result = $write_in_db( $this->getDataTableName(), $data_fields, $maybe_autoincrement_local_id_field_name ?? null );

		if ( false === $result ) {
			// Dev note: this logically should never happen.
			// TODO: Remove this check at a later date if confirmed.
			throw new WpDeployException( 'Error while saving trackable item in data table (logic error): ' . $wpdb_last_error );
		}

		if ( $usesCompositeLocalId ) {
			// Composite local IDs are not supposed to be generated by an auto_increment field.
			// They are built from the values of the fields that compose the local ID.
			$local_id = $maybe_local_id;

			// Set the local ID in case it is not set yet.
			$trackable_item->setLocalId( $local_id );
		} else {
			// Get the local ID of the item, which is the auto_increment value.
			$local_id = $wpdb_insert_id;

			if ( 0 === $local_id ) {
				// No row was inserted or updated. Content was identical.
				$local_id = $trackable_item->getLocalId();

				if ( empty( $local_id ) ) {
					// The item already exist, but is not yet tracked.
					// We know this because $wpdb_insert_id is 0, which means
					// that the "INSERT ... ON DUPLICATE KEY UPDATE" query
					// ended in an update with no rows updated.
					// $wpdb_insert_id is set by mysqli_insert_id(), which
					// do not return the ID of the updated row.
					// That's why we rely on $wpdb_last_insert_id, which is
					// set by "SELECT LAST_INSERT_ID()", which is more reliable
					// in such cases.
					$local_id = $mysql_last_insert_id;
				}

				if ( empty( $local_id ) ) {
					// The trackable item has no local ID assigned,
					// and it is not available in the database.
					// This should never happen, because it means that the last
					// insert / update found that the row already exist,
					// that the row has no autoincrement value,
					// that the item is not yet tracked either,
					// and even LAST_INSERT_ID() did not return something.
					throw new WpDeployException( 'Cannot retrieve the local ID of the item: ' . $trackable_item->getUuid() . ' from the tracker: ' . $this->getTrackerId() . '.' );
				}

				// Set the local ID on the trackable item to make sure it is
				// not unset.
				$trackable_item->setLocalId( $local_id );
			} else {
				// Either a new row was inserted or an existing row was updated.
				if ( ! empty( $maybe_local_id ) && is_numeric( $maybe_local_id ) && ( (int) $maybe_local_id !== $local_id ) ) {
					// The item was updated, but its local ID has changed.
					// This should never happen.
					throw new WpDeployException( 'The local ID of the item: ' . $trackable_item->getUuid() . ' from the tracker: ' . $this->getTrackerId() . ' was changed from: ' . $maybe_local_id . ' to: ' . $local_id . '.' );
				}

				// Set the local ID on the trackable_item, if not yet set.
				empty( $maybe_local_id ) && $trackable_item->setLocalId( $local_id );
			}
		}

		// Specify the ID in the metadata fields.
		// This is needed by the new imported items.
		$local_id_full_field_name_metadata = $this->getTrackingMetaDataTableName( false ) . '.local_id';

		// Make sure the local ID is set in the metadata fields.
		$metadata_fields[ $local_id_full_field_name_metadata ] = $metadata_fields[ $local_id_full_field_name_metadata ] ?? $local_id;

		if ( (string) $local_id !== (string) $metadata_fields[ $local_id_full_field_name_metadata ] ) {
			// This should never happen.
			// For single local IDs, this means that a local ID was provided from the outside of this method,
			// and it is different from the one generated by the $write_in_db function.
			// Either the import file is corrupted, or the $write_in_db function created a new row
			// instead of updating an existing one.
			// For composite local IDs, this should logically never really happen.
			throw new WpDeployException( 'The local ID retrieved by the $write_in_db function is different from the one provided in the metadata fields.' );
		}

		// Save into the metadata table.
		$write_in_db( $this->getTrackingMetaDataTableName(), $metadata_fields );
	}

	/**
	 * Separates the data fields from the meta fields.
	 *
	 * The fields in $metadata_fields and $data_fields arrays will be indexed by their name, not their full name.
	 * I.e. the field "posts.post_title" will be indexed as "post_title".
	 *
	 * @param array<string,mixed> $fields The fields to separate.
	 * @param array<string,mixed> $metadata_fields The metadata fields.
	 * @param array<string,mixed> $data_fields The data fields.
	 * @param bool                $preserve_tracker_prefixes Whether to preserve the tracker prefixes in the field
	 *     names. Defaults to true.
	 *
	 * @throws WpDeployException Cannot retrieve the table names of this tracker.
	 */
	protected function separateDataFromMetadata( array $fields, array &$metadata_fields, array &$data_fields, bool $preserve_tracker_prefixes = true ): void {
		$metadata_fields = array();
		$data_fields     = array();

		$metadata_fields_prefix = $this->getTrackingMetaDataTableName( false ) . '.';
		$data_fields_prefix     = $this->getDataTableName( false ) . '.';

		foreach ( $fields as $full_field_name => $value ) {
			if ( 0 === strpos( $full_field_name, $metadata_fields_prefix ) ) {
				// The current field is in the metadata table.
				if ( $preserve_tracker_prefixes ) {
					$metadata_fields[ $full_field_name ] = $value;
					continue;
				}

				$metadata_fields[ substr( $full_field_name, strlen( $metadata_fields_prefix ) ) ] = $value;
				continue;
			}

			if ( 0 !== strpos( $full_field_name, $data_fields_prefix ) ) {
				// The current field is neither in the metadata table nor in the data table.
				// Simply ignore it.
				continue;
			}

			// The current field is in the data table.
			if ( $preserve_tracker_prefixes ) {
				$data_fields[ $full_field_name ] = $value;
				continue;
			}

			$data_fields[ substr( $full_field_name, strlen( $data_fields_prefix ) ) ] = $value;
		}
	}

	/**
	 * Strips the reference prefix from the given UUID.
	 *
	 * The reference prefix is the tracker ID followed by a point.
	 *
	 * @param string $uuid The UUID of the tracked item. It accepts two formats:
	 *   - The UUID itself.
	 *   - The UUID prefixed with the tracker ID, separated by a point. In this case, the prefix
	 *     must be the same as the ID of this tracker.
	 *
	 * @return string The UUID without the reference prefix.
	 *
	 * @throws WpDeployException If the tracker ID is not set.
	 */
	public function stripReferencePrefixFromUuid( string $uuid ): string {
		$reference_prefix = $this->getTrackerId() . '.';

		if ( 0 === strpos( $uuid, $reference_prefix ) ) {
			// The UUID is prefixed with the tracker ID.
			list ( $tracker_id, $uuid ) = explode( '.', $uuid, 2 );

			if ( $tracker_id !== $this->getTrackerId() ) {
				throw new WpDeployException( 'The tracker ID of the given UUID does not match the tracker ID of this tracker.' );
			}
		}

		return $uuid;
	}

	/**
	 * 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 {
		$merged_item['fields'][ $this->getTrackingMetaDataTableName( false ) . '.merge_date' ] = $last_modification_date;
	}

	/**
	 * Tracks an existing given item.
	 *
	 * Call this method each time an item is created or updated;
	 * this will update the local last modification date of the item.
	 *
	 * @param array                    $local_ids The local ID array of the item(s) to track.
	 *     Should be integer-like for auto-incremented based trackers,
	 *     and PHP serialized strings for other types of trackers (composite keys).
	 * @param DateTimeInterface|null   $current_date The date and time of the last modification of the item.
	 *     Can be a DateTimeInterface object or null. If not provided, the current date and time will be used.
	 *     The track_mode in $options can override this behavior.
	 * @param array<string,mixed>|null $options The options to use when tracking the item.
	 *     - "no_update" (bool): Whether to skip the update of the last modification date if the item is
	 *       already tracked. Defaults to false.
	 *     - "track_mode" (string): The track mode. Either "TRACK_MODE__NORMAL" or "TRACK_MODE__TRACKER_INIT".
	 *
	 * @return array<string,string> The UUIDs of the tracked items, indexed by their local IDs.
	 *
	 * @throws WpDeployException The item could not be tracked.
	 */
	public function trackItemByLocalId( array $local_ids, ?DateTimeInterface $current_date = null, ?array $options = null ): array {
		// Basic filtering.
		$local_ids = array_filter( $local_ids );

		if ( empty( $local_ids ) ) {
			// Nothing to track.
			return array();
		}

		$no_update = $options['no_update'] ?? false;

		$wpdb = Database::getTrackingDatabaseHandle();

		$tmd_table_name = $this->getTrackingMetaDataTableName();

		$is_prod = $this->tracking_manager->isProductionEnvironment();

		$tracked_uuids = array();

		// Proceed with batch tracking.
		while ( ! empty( $local_ids ) ) {
			$local_ids_slice = array_splice( $local_ids, 0, 100 );

			$query_to_prepare = 'INSERT INTO %i (`uuid`, `last_modification_date`, `local_id`, `prod_sync_date`) VALUES ';
			$arguments        = array(
				$tmd_table_name,
			);

			$item_values = array();

			// This array contains UUIDs that are generated for the current batch.
			// It's used to avoid generating the same UUID twice, when two items
			// of the same batch give the same content hash.
			$new_uuids = array();

			foreach ( $local_ids_slice as $local_id ) {
				// Build the values for the current item.
				// Retrieve the existing uuid for this item, if any.
				$metadata = $this->getItemTrackingMetadata( $local_id );

				// The UUID generator will different results depending on multiple factors
				// such as the tracker ID, the environment type (post vs dev), and the track_mode.
				$uuid = $metadata['uuid'] ?? ( $this->uuid_random_generation ? null : $this->uuid->generateHashFromContent( $local_id, $this, array( 'track_mode' => $options['track_mode'] ?? static::TRACK_MODE__NORMAL ) ) );

				if ( empty( $uuid ) || ( ! empty( $new_uuids[ $uuid ] ) && ( $new_uuids[ $uuid ] !== $local_id ) ) ) {
					// The content hash is already used, either in the database or in the current batch.
					// Or this tracker uses randomly generated UUIDs.
					// Get a new generated UUID.
					$uuid = $this->uuid->generateUuidV4();
				}

				// Add the UUID to the list of generated UUIDs.
				$new_uuids[ $uuid ] = $local_id;

				if ( static::TRACK_MODE__TRACKER_INIT === ( $options['track_mode'] ?? null ) ) {
					// We have just enabled this tracker.
					// Get the initial track date for this item.
					// Dev note: we compute the initial track date only during this track mode
					// because it can be resource intensive (possibly multiple SQL queries).
					$final_current_date = $this->track_date_initializer->getInitialTrackDate( $this, $local_id )->format( Date::DB_DATETIME_FORMAT_MILLISECONDS );
				} else {
					// TRACK_MODE__NORMAL mode.
					$final_current_date = ( $current_date ?? Date::getCurrentDateTime() )->format( Date::DB_DATETIME_FORMAT_MILLISECONDS );
				}

				if ( empty( $metadata['prod_sync_date'] ) ) {
					// When tracking an item for the first time,
					// we set both the last modification date and the prod sync date to the current date.
					// Let's assign at least the current date;
					// this is required for importability detection to work.
					// It's one of the two times when a non-production environment can set the prod sync date;
					// the other is when the item is imported after a manual conflict resolution.
					$prod_sync_date = $final_current_date;
				} elseif ( $is_prod ) {
					// Production environment is required to set the prod_sync_date on each call of this method.
					$prod_sync_date = $final_current_date;
				} else {
					// Keep the existing prod_sync_date.
					$prod_sync_date = $metadata['prod_sync_date'];
				}

				$item_values[] = '(%s, %s, %s, %s)';

				$arguments[] = $uuid;
				$arguments[] = $final_current_date;
				$arguments[] = $local_id;
				$arguments[] = $prod_sync_date;

				$tracked_uuids[ $local_id ] = $uuid;
			}

			$query_to_prepare .= implode( ', ', $item_values );

			if ( $no_update ) {
				// Don't update the values if the item is already tracked.
				$query_to_prepare .= ' ON DUPLICATE KEY UPDATE `id` = `id`;';
			} else {
				$query_to_prepare .= ' ON DUPLICATE KEY UPDATE `last_modification_date` = VALUES(`last_modification_date`)';
				$query_to_prepare .= ', `prod_sync_date` = VALUES(`prod_sync_date`);';
			}

			// This will ensure we are not tracking changes on the tracking table itself.
			$query_to_prepare .= SqlChangesTracker::IGNORE_TRACKING;

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

			$this->retryableSqlWithLock( $tmd_table_name, $sql );
		}

		return $tracked_uuids;
	}

	/**
	 * Uninstalls the tracking for the managed item type.
	 *
	 * @throws WpDeployException Cannot uninstall the tracking properly.
	 */
	public function uninstallTracking(): void {
		$this->dropTrackingTriggers();

		Database::dropTable( $this->getTrackingMetaDataTableName(), false );

		// Tell the UUID service to clean up the tracking for this tracker.
		$this->uuid->resetTracking( $this );
	}

	/**
	 * Unloads the currently loaded trackable items.
	 *
	 * @throws WpDeployException Cannot get the trackable item registry.
	 */
	public function unloadTrackableItems(): void {
		$this->getTrackableItemRegistry()->clear();
	}

	/**
	 * Tells if this tracker uses a composite local ID (local ID based on multiple fields).
	 *
	 * @return bool True if this tracker uses a composite local ID, false otherwise.
	 *
	 * @throws WpDeployException The local ID field name is not set at all.
	 */
	public function usesCompositeLocalId(): bool {
		return is_array( json_decode( $this->getLocalIdFieldName(), true ) );
	}

	/**
	 * Untracks the given item.
	 *
	 * @param mixed $local_id The local ID of the item to untrack. Supports composite local IDs format.
	 *
	 * @throws WpDeployException The item could not be untracked.
	 */
	public function untrackItemByLocalId( $local_id ): void {
		if ( empty( $local_id ) ) {
			throw new WpDeployException( 'The local ID cannot be empty.' );
		}

		$wpdb = Database::getTrackingDatabaseHandle();

		$tmd_table_name = $this->getTrackingMetaDataTableName();

		$sql = $wpdb->prepare(
			'DELETE FROM %i WHERE `local_id` = %s;',
			$tmd_table_name,
			$local_id
		);

		$this->retryableSqlWithLock( $tmd_table_name, $sql );
	}
}
