<?php
/**
 * Item value handler that represents a value of a referenced item.
 *
 * @package Ealab\Deployer
 */

namespace Ealab\Deployer\Tracking\ItemValueHandler;

use Dakwamine\Component\RootDependencyDefinition;
use Ealab\Deployer\Exception\InvalidConfigException;
use Ealab\Deployer\Exception\WpDeployException;
use Ealab\Deployer\Tracking\FieldConfig;
use Ealab\Deployer\Tracking\ItemTracker\ItemTrackerBase;
use Ealab\Deployer\Tracking\TrackableItem;
use Ealab\Deployer\Tracking\TrackingManager;
use Ealab\Deployer\Utility\Database;
use Symfony\Component\Yaml\Yaml;

/**
 * Item value handler that represents a value of a referenced item.
 *
 * This is used when a field has a value that is set on a referenced item and
 * that is not the reference local ID.
 *
 * This exports an UUID. On import, this converts back the UUID to its item's
 * field value as set in the database. For this to work, the referenced item
 * MUST be exported as well in the same bundle to make sure we have the value
 * available for read during the import.
 */
class ReferenceFieldItemValueHandler extends ItemValueHandlerBase implements ReferenceItemValueHandlerInterface {
	/**
	 * Field in reference.
	 *
	 * When set, this is the field (column) name in the referenced item tracker
	 * that is really used in this field.
	 *
	 * For example, item in tracker A uses the field X of an item in tracker B.
	 * X is the real value.
	 *
	 * On export, we export the UUID of the item of tracker B
	 * that contains the value of this field in B.X.
	 *
	 * On import, we import the value B.X of the item of tracker B identified
	 * by the UUID.
	 *
	 * Please note we are only setting the dependency to the item in tracker B
	 * from the tracker A perspective,
	 * not any "nested" item which would have been referenced in X. Indeed,
	 * when exporting our A item, it requests the B item. And if the B item
	 * references let's say an item in tracker C, then the dependency is set
	 * in tracker B's configuration.
	 *
	 * @var string|null
	 */
	protected ?string $field_in_reference;

	/**
	 * Reference value in the form: "<tracker_id>.<uuid>".
	 *
	 * Use the getReferenceValue() method to get the value. It will determine it from $value if necessary.
	 *
	 * @var string|null
	 */
	protected ?string $reference_uuid = null;

	/**
	 * The referenced item. Use the getter to compute it.
	 *
	 * @var \Ealab\Deployer\Tracking\TrackableItem|null
	 */
	protected ?TrackableItem $referenced_item = null;

	/**
	 * The target type (aka tracker ID).
	 *
	 * @var string|null The target type.
	 */
	protected ?string $target_type;

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

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

		return $definitions;
	}

	/**
	 * {@inheritDoc}
	 */
	public static function getFormBuild(): ?array {
		$build = Yaml::parse(
			<<<YAML
reference_to:
  label:
    load: field_label
    content: Reference to...
  field:
    type: SelectField
    dataLocation: ~.reference_tracker_id
    dynamicOptions: ~~.enabledTrackers
field_in_reference:
  label:
    load: field_label
    content: Field in reference
  field: []
nullable:
  field:
    type: CheckBoxField
    dataLocation: ~.nullable
    options:
      - label: Nullable
        value: true
YAML
		);

		$build['reference_to']['label']['content'] = __( 'Reference to...', 'deploy-sync-content' );

		$build['field_in_reference']['label']['content'] = __( 'Field in reference', 'deploy-sync-content' );

		// Build one select per tracker (table), containing each tracker's fields (columns).
		$all_tables = Database::getTableNames( array( 'without_dsc_tables' => true ) );

		foreach ( $all_tables as $table ) {
			// Retrieve the table columns.
			$column_names = Database::getAllColumnNames( $table );

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

			$column_names_as_options = array(
				array(
					'label' => __( 'Please select a value...', 'deploy-sync-content' ),
					'value' => '',
				),
			);

			foreach ( $column_names as $column_name ) {
				$column_names_as_options[] = array(
					'label' => $column_name,
					'value' => $column_name,
				);
			}

			$build['field_in_reference']['field'][] = array(
				'type'         => 'SelectField',
				'actions'      => array(
					array(
						'what'  => 'hide',
						'when'  => '~.reference_tracker_id',
						'isNot' => Database::removeDatabasePrefix( $table ),
					),
				),
				'dataLocation' => '~.field_in_reference',
				'options'      => $column_names_as_options,
			);
		}

		return $build;
	}


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

	/**
	 * {@inheritDoc}
	 */
	public static function getLabel(): string {
		return _dpgettext( 'deployer-core', 'item value handler', 'Reference field value' );
	}

	/**
	 * Gets the tracker for the referenced item. This is based on the target_type property.
	 *
	 * @return ItemTrackerBase The tracker for the referenced item.
	 *
	 * @throws WpDeployException The tracker could not be retrieved. It's not available. In this case,
	 *   the plugin which provides the tracker is probably not active, or its definition is broken.
	 */
	private function getReferencedItemTracker(): ItemTrackerBase {
		// Get the tracker for the target type.
		$item_tracker = $this->tracking_manager->getItemTrackerByTrackerId( $this->target_type );

		if ( empty( $item_tracker ) ) {
			// No tracker for the target type.
			throw new WpDeployException( 'No tracker for the target type ' . $this->target_type . '.' );
		}

		return $item_tracker;
	}

	/**
	 * {@inheritDoc}
	 */
	public static function getReferencedItemTrackerIdFromRawValues( array $context = array() ): ?string {
		return $context['definition']['reference_tracker_id'] ?? null;
	}

	/**
	 * Gets the referenced trackable item.
	 *
	 * @return TrackableItem|null The referenced trackable item, or null if the item does not exist.
	 *
	 * @throws WpDeployException The item could not be retrieved due to a technical error.
	 */
	private function getReferencedTrackableItem(): ?TrackableItem {
		if ( $this->referenced_item ) {
			// We already have the item loaded once.
			return $this->referenced_item;
		}

		$referenced_item_tracker = $this->getReferencedItemTracker();

		if ( $this->reference_uuid ) {
			// Retrieve the item.
			// Force only_modified_force to false to ensure we can load referenced items
			// even if they haven't been modified recently (they are dependencies).
			return $referenced_item_tracker->loadTrackableItem(
				$this->reference_uuid,
				array( 'only_modified_force' => false )
			);
		}

		if ( ! $this->value ) {
			// There is no reference.
			return null;
		}

		// We have $this->value, which is the DB value.
		// We have to find the item that contains our value.
		// If multiple items are found, return the first.
		// We force the only_modified value to false, because we may not be able
		// to retrieve the referenced item if it has not been modified recently.
		$items = $referenced_item_tracker->loadTrackableItems(
			null,
			array(
				'data_field_conditions' => array( $this->field_in_reference => array( 'value' => $this->value ) ),
				'only_modified_force'   => false,
			)
		);

		// Take the first.
		$item = reset( $items );

		if ( ! empty( $item ) ) {
			$this->referenced_item = $item;

			$this->reference_uuid = $item->getUuid( true );

			return $item;
		}

		return null;
	}

	/**
	 * {@inheritDoc}
	 */
	protected function getUnprocessedValue( int $operation, ?callable $progress_callback = null ) {
		switch ( $operation ) {
			case ItemValueHandlerBase::OPERATION__EXPORT:
				if ( ! $this->value ) {
					// Return the empty value as-is.
					return $this->value;
				}

				// We return the full UUID of the item that contains our value.
				return $this->getReferencedTrackableItem()->getUuid( true ) ?? 0;

			case ItemValueHandlerBase::OPERATION__IMPORT__FINALIZE:
				// We want the local ID in this case.
				// Update the value with the local ID of the referenced item
				// because it may have been initialized with the UUID of the referenced item.
				if ( empty( $this->reference_uuid ) ) {
					// This is likely a no reference value.
					// It's a valid case, e.g. a term with no parent will use the "0" local ID.
					$this->value = $this->reference_uuid;

					return $this->value;
				}

				// Try to determine it from the reference ID.
				$referenced_item_tracker = $this->getReferencedItemTracker();

				$this->value = $referenced_item_tracker->getLocalIdFromUuid( $this->reference_uuid ) ?? 0;

				// We want the field in the referenced item as the final value
				// to be set in this field.
				// Load the referenced item.
				$referenced_item = $this->getReferencedTrackableItem();

				// Compute the field of the referenced item to get the real value.
				// Dev note: this may theoretically lead to an infinite loop if two trackers
				// reference themselves with a ReferenceFieldItemValueHandler.
				// That's why we limit with the "by_field_names" option. This limits
				// the cases when circular dependencies may infinitely loop;
				// the only case is when two fields reference each other, which should
				// logically never happen (unless due to a misconfiguration).
				return $referenced_item ? $referenced_item->getFieldValues(
					$operation,
					$progress_callback,
					array(
						'by_field_names'        => array( $this->field_in_reference ),
						'short_field_name_keys' => true,
					)
				)[ $this->field_in_reference ] ?? 0 : null;

			case ItemValueHandlerBase::OPERATION__IMPORT__PRESAVE:
				// Return a temporary and fake reference value.
				// This is required because the item which contains our
				// final value may not be imported yet.
				return 0;

			default:
				throw new WpDeployException( 'Invalid operation in ReferenceItemValueHandler::getValue() : ' . $operation . '.' );
		}
	}

	/**
	 * {@inheritDoc}
	 */
	public function init( TrackableItem $holder_item, string $full_field_name, $value, int $origin, FieldConfig $field_config ): void {
		parent::init( $holder_item, $full_field_name, $value, $origin, $field_config );

		$this->field_in_reference = $field_config->getRawConfig()['field_in_reference'] ?? null;

		if ( empty( $this->field_in_reference ) ) {
			throw new InvalidConfigException(
				sprintf(
					_dpgettext(
						'deployer-core',
						'item value handler',
						'The configuration key "field_in_reference" for field %s is not set on a ReferenceFieldItemValueHandler for the item: %s'
					),
					$full_field_name,
					$holder_item->getUuid()
				)
			);
		}

		$this->target_type = $field_config->getRawConfig()['reference_tracker_id'] ?? null;

		if ( empty( $this->target_type ) ) {
			throw new WpDeployException( sprintf( _dpgettext( 'deployer-core', 'item value handler', 'Invalid tracker ID for %s.' ), static::class ) );
		}

		if ( ! isset( $value ) ) {
			$nullable = $field_config->getRawConfig()['nullable'] ?? false;

			if ( ! empty( $nullable ) ) {
				// This field can be nullable, instead of having 0 as the "empty" value.
				$this->value          = null;
				$this->reference_uuid = null;

				return;
			}

			// Reference values are always set.
			throw new WpDeployException( 'No reference value provided.' );
		}

		switch ( $origin ) {
			case ItemValueHandlerBase::ORIGIN__DATABASE:
				// The value can be a value of any nature that
				// the referenced item row can contain.
				$this->value = $value;
				break;

			case ItemValueHandlerBase::ORIGIN__IMPORT_FILE:
				// The value is in the form: "<tracker_id>.<uuid>".
				$this->reference_uuid = $value;
				break;

			default:
				throw new WpDeployException( 'Invalid value origin: ' . $origin . '.' );
		}
	}

	/**
	 * {@inheritDoc}
	 */
	public static function normalizeFormSubmittedData( ?array $to_normalize = null ): ?array {
		if ( empty( $to_normalize ) ) {
			return null;
		}

		if ( empty( $to_normalize['reference_tracker_id'] ) ) {
			// Invalid config.
			return null;
		}

		if ( empty( $to_normalize['field_in_reference'] ) ) {
			// Invalid config.
			return null;
		}

		$normalized = parent::normalizeFormSubmittedData( $to_normalize );

		$normalized['reference_tracker_id'] = $to_normalize['reference_tracker_id'];
		$normalized['field_in_reference']   = $to_normalize['field_in_reference'];

		if ( ! empty( $to_normalize['nullable'] ) ) {
			$normalized['nullable'] = $to_normalize['nullable'];
		}

		return $normalized;
	}

	/**
	 * {@inheritDoc}
	 */
	public function processDependencies(): array {
		// This will collect the dependencies found by the processors attached to this item.
		$dependencies = parent::processDependencies();

		// At this point, all trackable items should be tracked.
		try {
			// Load the referenced item.
			$referenced_item = $this->getReferencedTrackableItem();
		} catch ( WpDeployException $e ) {
			// The referenced item does not exist.
			// "Disable" this item.
			$this->getHolder()->setDependencyValidityStatus( false );
			throw $e;
		}

		if ( empty( $referenced_item ) ) {
			// It's a no reference value.
			return $dependencies;
		}

		$this->getHolder()->addItemDependency( $referenced_item );

		$dependencies[ $referenced_item->getFullItemId() ] = $referenced_item;

		return $dependencies;
	}
}
