<?php
/**
 * Contains all the loaded trackable items.
 *
 * @package Ealab\Deployer
 */

namespace Ealab\Deployer\Tracking;

use Dakwamine\Component\ComponentBasedObject;
use Dakwamine\Component\Exception\UnmetDependencyException;
use Dakwamine\Component\RootDependencyDefinition;
use DateTime;
use Ealab\Deployer\Exception\WpDeployException;
use Ealab\Deployer\Tracking\ItemTracker\ItemTrackerBase;
use Ealab\Deployer\Tracking\ItemValueHandler\ItemValueHandlerBase;
use Ealab\Deployer\Utility\Database;
use Ealab\Deployer\Utility\Date;

/**
 * Contains a collection of trackable items.
 */
class TrackableItemCollection extends ComponentBasedObject {

	/**
	 * The item tracker which manages this collection.
	 *
	 * @var ItemTrackerBase
	 */
	private ItemTrackerBase $item_tracker;

	/**
	 * The screen label formatter manager.
	 *
	 * @var ScreenLabelFormatterManager|null
	 */
	private ?ScreenLabelFormatterManager $screen_label_formatter_manager = null;

	/**
	 * Array of trackable items indexed by their UUID.
	 *
	 * @var array<string, TrackableItem>
	 */
	private array $trackable_items = array();

	/**
	 * Clears the collection.
	 */
	public function clear(): void {
		$this->removeComponentsByClassName( TrackableItem::class );
		$this->trackable_items = array();
	}

	/**
	 * Creates an empty trackable item for a new trackable item.
	 *
	 * @param string $uuid The short UUID of the item to create.
	 *
	 * @return TrackableItem The created trackable item.
	 *
	 * @throws WpDeployException Cannot create a new item with a unique UUID.
	 */
	public function createEmptyTrackableItem( string $uuid ): TrackableItem {
		if ( empty( $uuid ) ) {
			throw new WpDeployException( 'Cannot create a new item with an empty UUID.' );
		}

		// Insert the item in the metadata table of the containing tracker.
		$sql = 'INSERT INTO %i (`uuid`) VALUES (%s);';

		$arguments   = array();
		$arguments[] = $this->getItemTracker()->getTrackingMetaDataTableName();
		$arguments[] = $uuid;

		$wpdb   = Database::getTrackingDatabaseHandle();
		$result = $wpdb->query( $wpdb->prepare( $sql, $arguments ) );

		if ( ! $result ) {
			// An item with this UUID already exists.
			throw new WpDeployException( 'Cannot provision a metadata entry.' );
		}

		try {
			/** @var TrackableItem $item */
			$item = $this->addComponentByClassName( TrackableItem::class );
		} catch ( UnmetDependencyException $e ) {
			// The item cannot be created.
			throw new WpDeployException( 'Cannot create the trackable item at all.', 0, $e );
		}

		$item
			->setRegistry( $this )
			->setUuid( $uuid );

		// Store the item.
		$this->trackable_items[ $uuid ] = $item;

		return $item;
	}

	/**
	 * Gets the trackable items already collected.
	 *
	 * @param array<string> $uuids The UUID of the item to get.
	 *
	 * @return array<TrackableItem> The trackable items.
	 */
	public function getCollectedTrackableItems( ?array $uuids = null ): array {
		if ( null === $uuids ) {
			return $this->trackable_items;
		}

		$uuids = array_filter( $uuids );

		if ( empty( $uuids ) ) {
			return array();
		}

		$return = array();

		foreach ( $this->trackable_items as $uuid => $item ) {
			if ( in_array( $uuid, $uuids, true ) ) {
				$return[ $uuid ] = $item;
			}
		}

		return $return;
	}

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

		return $definitions;
	}

	/**
	 * Gets the item tracker which manages this collection.
	 *
	 * @return ItemTrackerBase The item tracker.
	 *
	 * @throws WpDeployException The item tracker is not set.
	 */
	public function getItemTracker(): ItemTrackerBase {
		if ( empty( $this->item_tracker ) ) {
			throw new WpDeployException( 'No item tracker set for this collection.' );
		}

		return $this->item_tracker;
	}

	/**
	 * Gets a trackable item already collected.
	 *
	 * @param string $uuid The UUID of the item to get.
	 *
	 * @return TrackableItem|null The trackable item, or null if not yet loaded.
	 */
	public function getTrackableItemByUuid( string $uuid ): ?TrackableItem {
		return $this->trackable_items[ $uuid ] ?? null;
	}

	/**
	 * Loads the trackable items from the database.
	 *
	 * @param array|null          $uuids The UUIDs of the items to load. If null, all the eligible items are loaded.
	 * @param array<string,mixed> $options The options to pass to the loader.
	 *   - array data_field_conditions: If set, adds conditions on data fields.
	 *     Key is the field name, value is an associative array:
	 *     - value: the value to match.
	 *   - int|null limit: Limits the items count. Only in effect when $uuids is unset.
	 *   - bool only_modified: If true, only the modified items are loaded.
	 *     "only_modified" means "different dates between the last modification date and the prod sync date".
	 *     When the 'since' option and $uuids are unset, this option is ignored and the applied value is always true,
	 *     to prevent loading all the items into memory.
	 *     Otherwise, only_modified takes the option value, or false if unset.
	 *   - bool only_modified_force: if set, takes precedence on the default
	 *     rules of only_modified. Set to true to check only modified, set to
	 *     false to return any items regardless to their modification dates.
	 *   - \Datetime since: The date since which the items must be loaded. If unset, no date filter is applied.
	 *
	 * @return TrackableItem[] The loaded trackable items.
	 *
	 * @throws WpDeployException Cannot load the trackable items.
	 */
	public function loadTrackableItemsByUuid( ?array $uuids = null, array $options = array() ): array {
		if ( null !== $uuids ) {
			$uuids = array_filter( $uuids );

			if ( count( $uuids ) === 0 ) {
				return array();
			}

			$items = $this->getCollectedTrackableItems( $uuids );

			if ( count( $items ) === count( $uuids ) ) {
				return $items;
			}
		}

		// Some items are not yet loaded, or we want to load all the items.
		// We need the metadata and the values.
		$wpdb = Database::getTrackingDatabaseHandle();

		$data_table_name               = $this->getItemTracker()->getDataTableName();
		$tracking_meta_data_table_name = $this->getItemTracker()->getTrackingMetaDataTableName();

		// This will prefix the column names to avoid collisions
		// between the metadata and the data columns.
		Database::getAllColumnNamesPrefixedByTableName(
			array(
				$tracking_meta_data_table_name,
				$data_table_name,
			),
			$prefixed_column_names,
			$arguments
		);

		$sql         = 'SELECT ' . $prefixed_column_names . ' FROM %i JOIN %i ON ';
		$arguments[] = $tracking_meta_data_table_name;
		$arguments[] = $data_table_name;

		$tracker = $this->getItemTracker();

		if ( ! $tracker->usesCompositeLocalId() ) {
			$sql .= '%i.`local_id` = %i.%i';

			$arguments[] = $tracking_meta_data_table_name;
			$arguments[] = $data_table_name;
			$arguments[] = $tracker->getLocalIdFieldName();
		} else {
			$sql .= '%i.`local_id` = ';

			$arguments[] = $tracking_meta_data_table_name;

			$tracker->buildCompositeLocalIdValuesQuery( $sql, $arguments, array( 'table_name' => $data_table_name ) );
		}

		$has_added_where_clause = false;
		$add_where_once_or_and  = function () use ( &$has_added_where_clause, &$sql ) {
			if ( ! $has_added_where_clause ) {
				$sql .= ' WHERE';

				$has_added_where_clause = true;
			} else {
				$sql .= ' AND';
			}
		};

		$since = ( ! empty( $options['since'] ) && $options['since'] instanceof DateTime ) ? $options['since'] : null;

		// If the option only_modified_force is not explicitly set,
		// we prevent to return non-modified items
		// when UUIDs and since date are not supplied
		// to prevent returning too many items.
		$only_modified = $options['only_modified_force'] ?? ( empty( $uuids ) && empty( $since ) ) || ! empty( $options['only_modified'] );

		if ( $only_modified ) {
			// We want only modified items.
			$add_where_once_or_and();
			$sql .= ' %i.`last_modification_date` != %i.`prod_sync_date`';

			$arguments[] = $tracking_meta_data_table_name;
			$arguments[] = $tracking_meta_data_table_name;
		}

		if ( ! empty( $uuids ) ) {
			// Add the WHERE clause to filter on the UUIDs.
			$add_where_once_or_and();
			$sql .= ' %i.%i IN (' . implode( ',', array_fill( 0, count( $uuids ), '%s' ) ) . ')';

			$arguments[] = $tracking_meta_data_table_name;
			$arguments[] = 'uuid';

			foreach ( $uuids as $uuid ) {
				$arguments[] = $this->getItemTracker()->stripReferencePrefixFromUuid( $uuid );
			}
		}

		if ( ! empty( $since ) ) {
			// Add the WHERE clause to filter on the date.
			$add_where_once_or_and();
			$sql .= ' %i.`last_modification_date` >= %s';

			$arguments[] = $tracking_meta_data_table_name;
			$arguments[] = $since->format( Date::DB_DATETIME_FORMAT_MILLISECONDS );
		}

		foreach ( $this->getItemTracker()->getItemExclusionPatterns() as $field_name => $patterns ) {
			foreach ( $patterns as $pattern ) {
				$add_where_once_or_and();

				$sql .= ' %i.%i NOT LIKE %s';

				$arguments[] = $data_table_name;
				$arguments[] = $field_name;
				$arguments[] = $pattern;
			}
		}

		if ( ! empty( $options['data_field_conditions'] ) ) {
			// Additional filters on data field values.
			foreach ( $options['data_field_conditions'] as $data_field_name => $data_field_condition ) {
				$add_where_once_or_and();

				$sql .= ' %i.%i = %s';

				$arguments[] = $data_table_name;
				$arguments[] = $data_field_name;

				// TODO: this structure may contain the comparison type later.
				$arguments[] = $data_field_condition['value'];
			}
		}

		// Sort by last_modification_date to get the newest items first.
		$sql .= ' ORDER BY %i.`last_modification_date` DESC';

		$arguments[] = $tracking_meta_data_table_name;

		if ( ! empty( $uuids ) ) {
			// We know the maximum number of items to load.
			$sql .= ' LIMIT ' . count( $uuids );
		} elseif ( ! empty( $options['limit'] ) ) {
			$sql .= ' LIMIT %d';
			$arguments[] = $options['limit'];
		}

		$sql .= ';';

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

		$tracking_metadata_and_data = $wpdb->get_results( $prepared, ARRAY_A );

		if ( ! is_array( $tracking_metadata_and_data ) ) {
			throw new WpDeployException( 'Failed to load the tracking metadata and data.' );
		}

		$items = array();

		foreach ( $tracking_metadata_and_data as $single_tracking_metadata_and_data ) {
			// Get the trackable item for this row.
			$uuid = $single_tracking_metadata_and_data[ $this->getItemTracker()->getTrackingMetaDataTableName( false ) . '.uuid' ];

			$trackable_item = $this->getTrackableItemByUuid( $uuid );

			if ( ! empty( $trackable_item ) ) {
				// Already collected, so just add it to the list.
				$items[ $uuid ] = $trackable_item;
				continue;
			}

			try {
				// Not yet collected, so build it.
				$trackable_item = $this->addComponentByClassName( TrackableItem::class );
			} catch ( \Exception $e ) {
				// This should never happen. Why would the class not be found?
				throw new WpDeployException( 'Failed to create a trackable item.', 0, $e );
			}

			$trackable_item
				->setRegistry( $this )
				->setUuid( $uuid )
				->setOriginalDatabaseFieldValues( $single_tracking_metadata_and_data );

			// Set the plugin which handles this item.
			// Very important because it will tell the importer the plugins to install before importing the item.
			$trackable_item->setPlugin( $this->getItemTracker()->getPlugin() );

			// This will refresh the values for export. It may be rebuilt later if needed.
			$this->getItemTracker()->assignTrackableItemValues( $trackable_item, $single_tracking_metadata_and_data, ItemValueHandlerBase::ORIGIN__DATABASE );

			$this->trackable_items[ $uuid ] = $trackable_item;
			$items[ $uuid ]                 = $trackable_item;
		}

		return $items;
	}

	/**
	 * Sets the item tracker which manages this collection.
	 *
	 * @param ItemTrackerBase $item_tracker The item tracker.
	 *
	 * @return $this The current instance.
	 */
	public function setItemTracker( ItemTrackerBase $item_tracker ): TrackableItemCollection {
		$this->item_tracker = $item_tracker;

		return $this;
	}
}
