<?php
/**
 * Base class for UUID generators.
 */

namespace EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\UuidGenerator;

use EaLab\WP_Deployer\vendor\Dakwamine\Component\ComponentBasedObject;
use EaLab\WP_Deployer\vendor\Dakwamine\Component\RootDependencyDefinition;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Exception\WpDeployException;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\ItemTracker\ItemTrackerBase;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\ItemValueHandler\ItemValueHandlerBase;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\ItemValueHandler\ItemValueHandlerManager;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\TrackingManager;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Utility\Database;

/**
 * Base class for UUID generators.
 *
 * UUID generators generate an UUID from the given content and tracker config.
 * UUID generators are usually subcomponents of the Uuid component.
 */
class UuidGeneratorBase extends ComponentBasedObject {
	/**
	 * Item value handler manager.
	 *
	 * @var ItemValueHandlerManager|null
	 */
	private ?ItemValueHandlerManager $item_value_handler_manager = null;

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


	/**
	 * Generates a UUID based on the content.
	 *
	 * @param string                   $local_id The item's local ID.
	 * @param ItemTrackerBase          $item_tracker The item tracker which owns the item.
	 * @param array<string,mixed>|null $options Options:
	 *     - "track_mode", string: One of the ItemTrackerBase::TRACK_MODE__* constants.
	 *     - "value_prehash_callback", function: Optional callback to edit the value before it is hashed.
	 *
	 * @return string|null The generated UUID, or null if the UUID could not be generated.
	 *
	 * @throws WpDeployException Error on UUID generation.
	 *   It's not recommended to continue trying to build the UUID after this error
	 *   even as a fallback.
	 */
	public function generateHashFromContent(
		string          $local_id,
		ItemTrackerBase $item_tracker,
		?array          $options = null
	): ?string {
		$tracking_table_name        = $item_tracker->getTrackingMetaDataTableName();
		$table_name                 = $item_tracker->getDataTableName();
		$local_id_column_name       = $item_tracker->getLocalIdFieldName();
		$field_definition_overrides = $item_tracker->getFieldDefinitions();
		$source_columns             = $item_tracker->getUuidSourceFields();

		$wpdb = Database::getTrackingDatabaseHandle();

		// Build the query to get the content that will be used to generate the UUID.
		$query = <<<'SQL'
SELECT CONCAT(
               "SELECT ",
               GROUP_CONCAT(CONCAT('`', `COLUMN_NAME`, '`') ORDER BY `COLUMN_NAME`),
               " FROM %i WHERE 
SQL;

		$arguments = array(
			$table_name,
		);

		if ( ! $item_tracker->usesCompositeLocalId() ) {
			$query .= '%i.%i = %s';

			$arguments[] = $table_name;
			$arguments[] = $local_id_column_name;
			$arguments[] = $local_id;
		} else {
			// Composite local ID. We need to decompose it.
			$composite_local_ids = $item_tracker->getCompositeLocalIdValues( $local_id );

			$query_tmp = array();

			foreach ( $composite_local_ids as $column_name => $value ) {
				$query_tmp[] = '%i.%i = %s';
				$arguments[] = $table_name;
				$arguments[] = $column_name;
				$arguments[] = $value;
			}

			$query .= implode( ' AND ', $query_tmp );
		}

		// Close the main CONCAT.
		$query .= ';") ';

		if ( ! empty( $source_columns ) ) {
			// Build the query using $source_columns.
			// Note: The source columns should already be validated by the TrackerConfigProviderManager
			// upon tracker registration. So we don't need to validate them again here.
			$in_clause_placeholders = implode( ',', array_fill( 0, count( $source_columns ), '%s' ) );

			$query .= <<<'SQL'
FROM `information_schema`.`COLUMNS`
WHERE `TABLE_NAME` = %s
  AND `TABLE_SCHEMA` = database()
  AND `COLUMN_NAME` IN (
SQL. $in_clause_placeholders . ');';

			$arguments[] = $table_name;

			$arguments = array_merge( $arguments, $source_columns );
		} else {
			// Build the query using all eligible columns.
			$query .= <<<'SQL'
FROM `information_schema`.`COLUMNS`
WHERE `TABLE_NAME` = %s
  AND `TABLE_SCHEMA` = database()
  AND (`DATA_TYPE` = 'varchar' OR `DATA_TYPE` LIKE '%%text%%');
SQL;

			$arguments[] = $table_name;
		}

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

		// If the previous process worked as expected, $hashable_values_query will be something like this:
		// SELECT `field_a`,`field_b`,`field_x` FROM `table_name` WHERE `table_name`.`local_id_field_name` = 1905;.
		$hashable_values_query = $wpdb->get_var( $prepared );

		if ( empty( $hashable_values_query ) ) {
			// This can happen on tables without suitable columns for hashing.
			return null;
		}

		$value = $wpdb->get_row( $hashable_values_query, ARRAY_A );

		if ( empty( $value ) ) {
			// When trying to load the values of a trackable item, the given local ID did not exist (anymore).
			return null;
		}

		// Now, if there are any reference fields,
		// we want to replace their values by their UUID counterparts.
		foreach ( $field_definition_overrides as $reference_field_name => $reference_field_config ) {
			if ( empty( $reference_field_config['value_handler'] ) || ! is_string( $reference_field_config['value_handler'] ) ) {
				continue;
			}

			// Retrieve the class that contains the handler.
			$ivh_class = $this->item_value_handler_manager->getHandlerClassNameById( $reference_field_config['value_handler'] );

			if ( empty( $ivh_class ) || ! ( $ivh_class instanceof ItemValueHandlerBase ) ) {
				// Invalid value handler; should break the code at some point.
				continue;
			}

			if ( ! method_exists( $ivh_class, 'getReferencedItemTrackerIdFromRawValues' ) ) {
				// The handler is not a reference-type handler. No need to convert its value.
				continue;
			}

			// By sending this anonymous function in argument,
			// we allow the item value handler to retrieve the row content if
			// it ever needs them to determine which tracker contains the
			// referenced item.
			$reference_tracker_id = $ivh_class::getReferencedItemTrackerIdFromRawValues(
				array(
					'definition'    => $reference_field_config,
					'getContentRow' => static function () use ( $wpdb, $local_id, $table_name, $local_id_column_name ) {
						return $wpdb->get_row(
							$wpdb->prepare(
								'SELECT * FROM %i WHERE %i = %s;',
								array(
									$table_name,
									$local_id_column_name,
									$local_id,
								)
							),
							ARRAY_A
						);
					},
				)
			);

			if ( empty( $reference_tracker_id ) ) {
				// There is no reference for this instance of field.
				// No need to convert it.
				// This happens for fields that may contain references or not.
				continue;
			}

			foreach ( $value as $field_name => $field_value ) {
				if ( $field_name !== $reference_field_name ) {
					// Not the field we are currently processing.
					continue;
				}

				// Get the tracker of the referenced item.
				$ref_tracker = $this->tracking_manager->getItemTrackerByTrackerId( $reference_tracker_id );

				if ( empty( $ref_tracker ) ) {
					// This should never happen because this means the tracker is not available
					// and the item references an item from it.
					// Either the reference configuration has to be removed,
					// or the tracker has to be added to the configuration.
					throw new WpDeployException( _dgettext( 'deployer-core', 'An item references another item which is from a non existing tracker.' ) );
				}

				if ( empty( $field_value ) ) {
					// Empty reference.
					continue;
				}

				$ref_uuid = $ref_tracker->getItemUuidByLocalId( $field_value );

				if ( empty( $ref_uuid ) ) {
					// The referenced item does not exist, or this value does not reference an item.
					$message = sprintf(
						_dgettext( 'deployer-core', 'The referenced item does not exist, or this value does not reference an item. Item type: %1$s, local ID: %2$s.' ),
						$reference_tracker_id,
						$field_value
					);

					throw new WpDeployException( $message );
				}

				// Finally replace the value.
				$value[ $field_name ] = $ref_uuid;

				break;
			}
		}

		// Sort the fields by key in order to keep the hash consistent
		// when the order of the fields in the database changes.
		ksort( $value );

		if ( ! empty( $options['value_prehash_callback'] ) ) {
			// Allow an alteration callback to edit the value.
			$options['value_prehash_callback']( $value );
		}

		// Remove host name occurrences from the hashable values.
		// This is necessary because the host name can change between
		// environments, which would cause the hash to change for a same
		// content.
		$current_host_name = get_option( 'siteurl' );

		if ( $current_host_name ) {
			// This may result in empty values, but that's fine.
			$value = array_map(
				function ( $field_value ) use ( $current_host_name ) {
					return is_string( $field_value )
						? str_replace( $current_host_name, '', $field_value )
						: $field_value;
				},
				$value
			);
		}

		// We add the table name to the values.
		// This will reduce the risk of collisions between items from different tables.
		// Of course, such items are stored in different tracking tables,
		// but it's better for the user to have a different UUID for each item,
		// when their hashable content is the same
		// (which rarely happens, but heh, we are only adding a short string to the hash.).
		$final_value = array(
			'content'    => $value,
			'table_name' => Database::removeDatabasePrefix( $table_name ),
		);

		// This will serialize the values to JSON, hash with SHA-1 (40 chars) and truncate to 36 chars
		// so that it can be stored in a UUID v4 column (UUID v4 is 36 chars).
		$hash = substr( sha1( json_encode( $final_value ) ), 0, 36 );

		// Check if the hash is already used for another item.
		$count = $wpdb->get_var(
			$wpdb->prepare(
				<<<'SQL'
SELECT COUNT(*) FROM %i WHERE `uuid` = %s AND `local_id` != %s;
SQL,
				$tracking_table_name,
				$hash,
				$local_id
			)
		);

		if ( $count > 0 ) {
			// The hash is already used.
			// This can happen when the hashable content is the same for two items.
			return null;
		}

		return $hash;
	}

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

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

		return $definitions;
	}

	/**
	 * Resets the tracking info.
	 *
	 * @param ItemTrackerBase $item_tracker The item tracker to reset its tracking data.
	 */
	public function resetTracking( ItemTrackerBase $item_tracker ) {
		// The default implementation does nothing.
	}
}
