<?php
/**
 * Tracker configuration provider manager.
 *
 * @package Ealab\Deployer
 */

namespace Ealab\Deployer\Tracking\TrackerConfig;

use Dakwamine\Component\RootDependencyDefinition;
use Ealab\Deployer\Exception\EaLabWebServiceException;
use Ealab\Deployer\Exception\LicenceException;
use Ealab\Deployer\Exception\LicenceKeyUnsetException;
use Ealab\Deployer\Exception\WpDeployException;
use Ealab\Deployer\Tracking\Config\ConfigProviderManagerBase;
use Ealab\Deployer\Tracking\TrackingManager;
use Ealab\Deployer\Utility\Database;

/**
 * Tracker configuration provider manager.
 */
class TrackerConfigProviderManager extends ConfigProviderManagerBase {
	/**
	 * The tracking manager.
	 *
	 * @var TrackingManager|null
	 */
	private ?TrackingManager $tracking_manager = null;

	/**
	 * Gets the active tracker config.
	 *
	 * It may be outdated, so it is recommended to call updateCurrentConfig() if an updated config is required.
	 * With the default options, this will return the current saved config, but will also remove invalid tracker
	 * configs (without data tables) from the returned values.
	 *
	 * @param array<string,mixed>|null $options The options to use for collecting the tracker definitions.
	 *   - 'remove_config_without_data_tables' (bool) Whether to remove the tracker config if it does not have any data
	 *     table. True by default if the option is unset.
	 *
	 * @return array<string,array> The item trackers definitions, keyed by tracker ID.
	 *
	 * @throws WpDeployException If a tracker config source could not be instantiated.
	 *
	 * @deprecated Use getActiveConfig() instead.
	 */
	public function getActiveTrackerConfig( ?array $options = null ): array {
		return $this->getActiveConfig( $options );
	}

	/**
	 * {@inheritDoc}
	 *
	 * @throws EaLabWebServiceException Repository web service error.
	 * @throws LicenceException Licence is invalid.
	 * @throws LicenceKeyUnsetException Licence key is missing.
	 */
	public function getActiveConfig( ?array $options = null ): array {
		if ( null !== $this->config_override ) {
			return $this->config_override;
		}

		$config = parent::getActiveConfig( $options );

		if ( ! empty( $options['remove_config_without_data_tables'] ) || ! isset( $options['remove_config_without_data_tables'] ) ) {
			// This helps to prevent issues when the tracker config is not up-to-date
			// regarding the data tables.
			$config = $this->removeConfigsWithoutDataTables( $config );
		}

		/**
		 * Item trackers definitions prepared for use.
		 *
		 * Here is an example of how a tracker is represented:
		 *
		 * array(
		 *     '<tracker_id>' => array(
		 *         'exclude'                     => <exclude_tracker_true_false>,
		 *         'class'                       => '<item_tracker_class_name>', // Optional, defaults to ItemTrackerBase.
		 *         'field_definitions_overrides' => array( // Optional, allows to override the field definitions of the tracker.
		 *             '<field_name>' => array(
		 *                 'exclude'        => <exclude_true_false>,
		 *                 'exclude_values' => <array_of_patterns>,
		 *                 'value_handler'  => '<item_value_handler_full_id>', // If not set, the default value handler is "scalar".
		 *                 'processors'     => array(
		 *                     array(
		 *                         'id'      => '<processor_id>',
		 *                         '<any_key>' => <processor_config>,
		 *                     ),
		 *                 ),
		 *             ),
		 *         ),
		 *         'local_id_field'             => array(
		 *             'name' => '<local_id_field_name>',
		 *             'type' => '<local_id_field_type>',
		 *         ),
		 *         'plugin'                      => '<plugin>',
		 *         'screen_label_formatter'      => '<screen_label_formatter>',
		 *         'subordination'               => array(
		 *           'primary_tracker_id'    => '<primary_tracker_id>',
		 *           'primary_item_id_field' => '<primary_item_id_field>',
		 *         ),
		 *         'tracker_id'                  => '<tracker_id>',
		 *     ),
		 * );
		 *
		 * Explanation of the placeholders:
		 * - <exclude_tracker_true_false>: Whether to exclude the tracker from the export or not.
		 * - <field_name>: The name of a field (= column for databases) to override.
		 * - <item_tracker_class_name>: The class name of the tracker. It must extend ItemTrackerBase.
		 * - <exclude_true_false>: Whether to exclude the field from the export or not.
		 * - <item_value_handler_full_id>: The full ID of the item value handler to use for the field: scalar, reference:options, etc.
		 * - <processor_id>:
		 *   ID of an item value processor which will transform the value on export and import.
		 *   Processors are optional.
		 * - <processor_config>:
		 *   Optional config of the processor, indexed by <any_key> (they will be passed to the processor).
		 * - <local_id_field_name>: This is the name of one of the composite local ID fields.
		 *   E.g. for the "options" tracker, the local ID is composed of the "option_id" field.
		 *   Local ID fields must be autoincremented fields (for DB sourced items).
		 * - <local_id_field_type>: The type of the local ID field.
		 *   E.g. for the "options" tracker, the "option_id" field is of type "bigint(20) unsigned".
		 * - <plugin>: The plugin that owns the tracker. If unset, the tracker is considered as a core tracker.
		 * - <subordination>: If set, the items of the tracker will be considered as subordinated to the items
		 *   of the tracker with the given ID <primary_tracker_id>, using <primary_item_id_field> as the field
		 *   containing the ID of the primary item from the subordinated tracker.
		 *   This is useful for items that will be displayed
		 *   as sub-items of the items of the other tracker, such as postmeta and termmeta.
		 * - <screen_label_formatter>: The screen label formatter config to use for the tracker.
		 *   Usually something like: array( 'id' => 'field', 'config' => array( 'field_name' => 'post_title' ) ).
		 * - <tracker_id>: The ID of the tracker. It must be unique. It must match the table name for database sourced trackers.
		 *
		 * @var array<string,array>|null The item trackers definitions, keyed by tracker ID.
		 */
		return $config;
	}

	/**
	 * @throws WpDeployException If a tracker config source could not be instantiated.
	 * @deprecated Use getActiveConfig() instead.
	 *
	 */
	public function getActiveTrackerDefinitions( ?array $options = null ): array {
		return $this->getActiveConfig( $options );
	}

	/**
	 * {@inheritDoc}
	 */
	protected function getBaseConfigProviderDefinitions(): array {
		return array(
			array(
				'class' => RepositoryTrackerConfigProvider::class,
			),
		);
	}

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

		$definitions[] = new RootDependencyDefinition( TrackingManager::class, $this->tracking_manager );

		return $definitions;
	}

	/**
	 * Gets exclusions current config.
	 *
	 * @param array<string,mixed>|null $options The options to use for collecting the tracker definitions.
	 *   See getActiveTrackerConfig() for the available options.
	 *
	 * @return array<string,array> The item trackers definitions, keyed by tracker ID.
	 *
	 * @throws WpDeployException Cannot get the active tracker config.
	 * @deprecated Used by the old advanced settings UI.
	 */
	public function getExclusionsTrackerDefinitions( ?array $options = null ): array {
		$current_config_tracker = $this->getActiveConfig( $options );

		$exclusions = array();

		foreach ( $current_config_tracker as $key => $item ) {
			if ( ! empty( $item['item_exclusion_patterns'] ) ) {
				foreach ( $item['item_exclusion_patterns'] as $field => $exclusion ) {
					$exclusions[] = array(
						$key => array( $field => $exclusion ),
					);
				}
			}
		}

		return $exclusions;
	}

	/**
	 * {@inheritDoc}
	 */
	public static function getManagerId(): string {
		return 'tracker';
	}

	/**
	 * {@inheritDoc}
	 */
	protected function onCollectedConfig( array &$collected_config ): void {
		parent::onCollectedConfig( $collected_config );

		// Remove the trackers which have no data tables.
		$removed_trackers = array();
		$collected_config = $this->removeConfigsWithoutDataTables( $collected_config, $removed_trackers );

		// Remove old tracking tables.
		// We have to load them here because the tracking manager may not have the definitions of the removed trackers
		// loaded into memory.
		$this->tracking_manager->removeTracking(
			array_map(
				function ( $tracker_conf ) {
					return $this->tracking_manager->loadItemTrackerByDefinition( $tracker_conf );
				},
				array_filter( $removed_trackers )
			)
		);
	}

	/**
	 * {@inheritDoc}
	 */
	public function normalizeConfig( array &$config ): void {
		// Order the trackers.
		// This is required because in the given config,
		// the subordinated trackers may be listed before the trackers they are subordinated to,
		// and we need to make sure that the subordinated trackers' items will be able to use the
		// UUIDs of their parents for UUID generation.
		$config = $this->orderTrackerConfig( $config );

		// Sort the composite local IDs for trackers using them.
		$this->orderCompositeLocalIds( $config );

		$standard_sorted_keys = array(
			'field_definitions_overrides',
			'local_id_field',
			'screen_label_formatter',
			'subordination',
			'uuid_source_fields',
		);

		foreach ( $config as &$tracker_config ) {
			// Sort the root keys of the tracker config.
			ksort( $tracker_config, SORT_NATURAL );

			foreach ( $standard_sorted_keys as $key ) {
				if ( ! array_key_exists( $key, $tracker_config ) ) {
					continue;
				}

				// Sort the keys inside.
				$to_sort = &$tracker_config[ $key ];

				if ( ! is_array( $to_sort ) ) {
					continue;
				}

				ksort( $to_sort, SORT_NATURAL );
			}
		}
	}

	/**
	 * Orders the composite local IDs fields by their name.
	 *
	 * @param array<string,array<string,mixed>> $config The config, passed by reference.
	 */
	private function orderCompositeLocalIds( array &$config ): void {
		foreach ( $config as &$tracker ) {
			$local_id_name = $tracker['local_id_field']['name'] ?? '';

			$decoded = json_decode( $local_id_name, true );

			if ( ! is_array( $decoded ) ) {
				// Not a composite local ID.
				continue;
			}

			sort( $decoded );

			$tracker['local_id_field']['name'] = json_encode( $decoded );
		}
	}

	/**
	 * Orders the tracker config.
	 *
	 * @param array<string,array<string,mixed>> $config The tracker config.
	 *
	 * @return array<string,array<string,mixed>> The ordered tracker config.
	 *
	 * @throws WpDeployException Error on config.
	 */
	private function orderTrackerConfig( array $config ): array {
		// Start with a simple sort by tracker ID.
		// This will ensure that the final order is always the same
		// for the same tracker config, regardless of the input order.
		ksort( $config );

		$ordered_tracker_config = array();

		// Use the subordination and uuid_source_field config to order the trackers.
		// Subordinated and uuid_source_field based trackers will be placed after the trackers they depend on.
		// Start by separating the trackers that have a subordination config from the others.
		$trackers_to_place_after = array();
		$result                  = array();

		// Cache for the required trackers for UUID generation.
		$tracker_required_trackers = array();

		$get_required_trackers_for_uuid_generation = function ( $tracker_config ) use ( $config, &$tracker_required_trackers ) {
			if ( empty( $tracker_config['uuid_source_fields'] ) ) {
				return null;
			}

			if ( empty( $tracker_config['tracker_id'] ) ) {
				throw new WpDeployException( sprintf( _dgettext( 'deployer-core', 'Bad configuration: A tracker ID is missing. Here is the JSON representation of the failing configuration: %s' ), json_encode( $tracker_config ) ) );
			}

			foreach ( $tracker_config['uuid_source_fields'] as $uuid_source_field ) {
				// Find reference fields in the uuid_source_fields.
				if ( substr( $tracker_config['field_definitions_overrides'][ $uuid_source_field ]['value_handler'] ?? '', 0, strlen( 'reference:' ) ) !== 'reference:' ) {
					continue;
				}

				$required_tracker_id = substr( $tracker_config['field_definitions_overrides'][ $uuid_source_field ]['value_handler'], strlen( 'reference:' ) );

				if ( empty( $required_tracker_id ) ) {
					// Invalid reference field.
					throw new WpDeployException( sprintf( _dgettext( 'deployer-core', 'Invalid reference field found in tracker %s' ), $tracker_config['tracker_id'] ?? _dgettext( 'deployer-core', 'Unknown tracker' ) ) );
				}

				$tracker_required_trackers[ $tracker_config['tracker_id'] ][ $required_tracker_id ] = $required_tracker_id;
			}

			return $tracker_required_trackers[ $tracker_config['tracker_id'] ] ?? null;
		};

		foreach ( $config as $tracker_id => $tracker ) {
			if ( ! empty( $tracker['subordination']['primary_tracker_id'] ) ) {
				$trackers_to_place_after[ $tracker_id ] = $tracker;
			} elseif ( ! empty( $get_required_trackers_for_uuid_generation( $tracker ) ) ) {
				$trackers_to_place_after[ $tracker_id ] = $tracker;
			} else {
				// Trackers without subordination are placed at the beginning.
				$result[ $tracker_id ] = array(
					'level'   => 0,
					'tracker' => $tracker,
				);
			}
		}

		// Order the trackers with subordination.
		while ( ! empty( $trackers_to_place_after ) ) {
			$start_count = count( $trackers_to_place_after );

			$trackers_with_subordination_new_list_in_loop = $trackers_to_place_after;

			foreach ( $trackers_to_place_after as $tracker_id => $tracker ) {
				// Start by checking the UUID source fields requirements.
				$uuid_source_fields_required_trackers = $get_required_trackers_for_uuid_generation( $tracker );

				if ( ! empty( $uuid_source_fields_required_trackers ) ) {
					foreach ( $uuid_source_fields_required_trackers as $required_tracker ) {
						if ( ! array_key_exists( $required_tracker, $result ) ) {
							// The required tracker is not yet added to the result.
							continue 2;
						}
					}
				}

				// Now check using the subordination config, if any.
				$subordinated_to = $tracker['subordination']['primary_tracker_id'] ?? null;

				if ( ! empty( $subordinated_to ) && ! array_key_exists( $subordinated_to, $result ) ) {
					// The $subordinated_to tracker is not yet available in the result.
					// The tracker this tracker is subordinated to has not already been placed.
					continue;
				}

				// Place this tracker "after" all required trackers.
				$last_required_tracker_level = ! empty( $subordinated_to ) ? $result[ $subordinated_to ]['level'] : 0;

				foreach ( ( $uuid_source_fields_required_trackers ?? array() ) as $required_tracker ) {
					$last_required_tracker_level = max( $last_required_tracker_level, $result[ $required_tracker ]['level'] );
				}

				$result[ $tracker_id ] = array(
					'level'   => $last_required_tracker_level + 1,
					'tracker' => $tracker,
				);

				// Remove this tracker from the list of trackers with subordination.
				unset( $trackers_with_subordination_new_list_in_loop[ $tracker_id ] );
			}

			if ( count( $trackers_with_subordination_new_list_in_loop ) === $start_count ) {
				// No tracker has been removed, so there is a circular dependency,
				// or a tracker is subordinated to a tracker that does not exist.
				throw new WpDeployException( _dgettext( 'deployer-core', 'The tracker config contains a circular dependency between trackers, or a tracker is subordinate to another tracker that does not exist.' ) );
			}

			$trackers_to_place_after = $trackers_with_subordination_new_list_in_loop;
		}

		// Sort the trackers by level.
		uasort(
			$result,
			function ( $a, $b ) {
				return $a['level'] <=> $b['level'];
			}
		);

		foreach ( $result as $tracker_id => $tracker ) {
			// Recompose the config to the original format.
			$ordered_tracker_config[ $tracker_id ] = $tracker['tracker'];
		}

		return $ordered_tracker_config;
	}

	/**
	 * {@inheritDoc}
	 */
	protected function postSave( array &$config ): void {
		// Initialize the metadata tables if needed.
		// Send the $config variable because this is the final config.
		$this->tracking_manager->initializeTracking( $config );
	}

	/**
	 * Removes the tracker configs that do not have a matching data table.
	 *
	 * @param array<string,array<string,mixed>> $config The tracker configs to filter.
	 * @param array<string,array<string,mixed>> $removed_trackers The tracker configs that have been removed.
	 *
	 * @return array The filtered tracker configs.
	 */
	private function removeConfigsWithoutDataTables( array $config, array &$removed_trackers = array() ): array {
		$existing_table_names = array_flip(
			Database::getTableNames(
				array(
					'without_prefix'            => true,
					'without_dsc_tables' => true,
				)
			)
		);

		// Dev note: this may be used to store the removed trackers for later use, like reverting the changes.
		$removed_trackers = array_diff_key(
			$config,
			$existing_table_names
		);

		return array_intersect_key(
			$config,
			$existing_table_names
		);
	}

	/**
	 * Validates the config.
	 *
	 * @param array $config The config to validate.
	 *
	 * @throws WpDeployException If the config is invalid. The message contains the error.
	 */
	public function validateConfig( array $config ): void {
		$this->validateTrackerSubordination( $config );
	}

	/**
	 * Validates the tracker subordination.
	 *
	 * @param array $config The config to validate.
	 *
	 * @throws WpDeployException If the config is invalid. The message contains the error.
	 */
	private function validateTrackerSubordination( array $config ): void {
		// Parse every tracker config to determine if there is a circular dependency.
		foreach ( $config as $tracker_id => $tracker_config ) {
			$subordinated_to = $tracker_config['subordination']['primary_tracker_id'] ?? null;

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

			// Check if the subordinated tracker exists.
			if ( empty( $config[ $subordinated_to ] ) ) {
				throw new WpDeployException( sprintf( _dgettext( 'deployer-core', 'Tracker "%s" is subordinated to "%s", but the latter does not exist.' ), $tracker_id, $subordinated_to ) );
			}

			// Check if the subordinated tracker is subordinated to the current tracker.
			$subordinated_tracker_subordination = $config[ $subordinated_to ]['subordination']['primary_tracker_id'] ?? null;

			if ( $subordinated_tracker_subordination === $tracker_id ) {
				// Circular dependency.
				throw new WpDeployException( sprintf( _dgettext( 'deployer-core', 'Circular dependency between trackers "%s" and "%s".' ), $tracker_id, $subordinated_to ) );
			}
		}
	}

	/**
	 * Validates the UUID source columns.
	 *
	 * @param array $config The config to validate.
	 *
	 * @throws WpDeployException If the config is invalid. The message contains the error.
	 *
	 * @deprecated This is no longer needed. Reference fields are now allowed as UUID source columns.
	 */
	private function validateUuidSourceColumns( array $config ): void {
		foreach ( $config as $tracker_id => $tracker ) {
			foreach ( ( $tracker['uuid_source_fields'] ?? array() ) as $uuid_source_field ) {
				// Check that the source columns are not reference fields.
				if ( 0 === strpos( $tracker['field_definitions_overrides'][ $uuid_source_field ]['value_handler'] ?? '', 'reference:' ) ) {
					throw new WpDeployException( sprintf( _dgettext( 'deployer-core', 'Tracker "%1$s" has a reference field "%2$s" as UUID source field; this is not allowed.' ), $tracker_id, $uuid_source_field ) );
				}
			}
		}
	}
}
