<?php
/**
 * Base class for item value handler for reference values.
 *
 * @package Ealab\Deployer
 */

namespace Ealab\Deployer\Tracking\ItemValueHandler;

use Dakwamine\Component\RootDependencyDefinition;
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 Symfony\Component\Yaml\Yaml;

/**
 * Base class for item value handler for reference values.
 *
 * This is used when a tracked item value is a reference to another item in the database.
 */
class ReferenceItemValueHandler extends ItemValueHandlerBase implements ReferenceItemValueHandlerInterface {
	/**
	 * Gets the target type.
	 *
	 * This is analogous to the IDs of the available item trackers.
	 *
	 * @var string|null The target type.
	 */
	protected ?string $target_type;

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

	/**
	 * 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_value = 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
label:
  load: field_label
  content: Reference to...
select:
  type: SelectField
  dataLocation: ~.reference_tracker_id
  dynamicOptions: ~~.enabledTrackers
subitem:
  type: CheckBoxField
  dataLocation: ~.isSubitemOf
  options:
    - label:
        # TODO: Add the tooltip.
        - Set as dependency
      value: true
nullable:
  field:
    type: CheckBoxField
    dataLocation: ~.nullable
    options:
      - label: Nullable
        value: true
YAML
		);

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

		$build['subitem']['options'][0]['label'] = array(
			__( 'Set as dependency', 'deploy-sync-content' ),
		);

		return $build;
	}

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

	/**
	 * {@inheritDoc}
	 */
	public static function getLabel(): string {
		return _dpgettext( 'deployer-core', 'item value handler', 'Reference 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}
	 *
	 * @throws \Ealab\Deployer\Exception\WpDeployException Invalid reference config given.
	 */
	public static function getReferencedItemTrackerIdFromRawValues( array $context = array() ): ?string {
		// We just need to parse the value_handler ID in the definition.
		$reference_tracker_id = explode( ':', $context['definition']['value_handler'], 3 );

		if ( count( $reference_tracker_id ) !== 2 ) {
			throw new WpDeployException( 'Invalid reference config given: ' . $context['definition'] );
		}

		return $reference_tracker_id[1];
	}

	/**
	 * 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 ( empty( $this->target_type ) ) {
			return null;
		}

		$reference_value = $this->getReferenceValue();

		if ( empty( $reference_value ) ) {
			// There is no reference. It's a valid case, e.g. a term with no parent will use the "0" local ID.
			return null;
		}

		// Load the trackable 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).
		$items = $this->getReferencedItemTracker()->loadTrackableItems(
			array( $reference_value ),
			array( 'only_modified_force' => false )
		);

		if ( empty( $items ) ) {
			// The referenced item identified by a non-null reference value does not exist or is excluded.
			// Dev note: a change has been made here so that null is returned instead of
			// triggering a WpDeployException here. Revert to the exception instead of returning null if this
			// causes unexpected issues.
			// We return null because we may want to deploy the holder item even though the referenced item is
			// excluded (= not allowed to export) or inexistant (= dead reference).
			// For cases such as postmeta referencing an excluded post, this issue is resolved naturally because
			// the post would not be selectable in the UI, then the postmeta would not be selectable either (because
			// the postmeta is a subordinated tracker). But for non-subordinated trackers, the item would then be
			// allowed to be exported/imported.
			return null;
		}

		return reset( $items );
	}

	/**
	 * Gets the reference value.
	 *
	 * It's in the form: "<tracker_id>.<uuid>". If not set, it will be determined from $this->value.
	 *
	 * @return string|null The reference value, or null if the reference is empty.
	 *
	 * @throws WpDeployException The reference value could not be determined.
	 */
	private function getReferenceValue(): ?string {
		if ( empty( $this->value ) ) {
			// The value is 0 (or empty), which means that the reference is empty.
			// Return the value as-is. It's a valid case, e.g. a term with no parent will use the "0" local ID.
			return $this->value;
		}

		if ( ! isset( $this->reference_value ) ) {
			try {
				// The reference value is not yet set.
				// Determine it from the value.
				$this->reference_value = $this->target_type . '.' . $this->getUuidFromLocalId();
			} catch ( WpDeployException $e ) {
				// The referenced item does not exist, or this value does not reference an item.
				return null;
			}
		}

		return $this->reference_value;
	}

	/**
	 * {@inheritDoc}
	 */
	protected function getUnprocessedValue( int $operation, ?callable $progress_callback = null ) {
		switch ( $operation ) {
			case ItemValueHandlerBase::OPERATION__EXPORT:
				// Export the reference value.
				return $this->getReferenceValue();

			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_value ) ) {
					// 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.
					// The exporter uses the value as-is, so we must do the same on import.
					$this->value = $this->reference_value;

					return $this->value;
				}

				// Try to determine it from the reference ID.
				$this->value = $this->getReferencedItemTracker()->getLocalIdFromUuid( $this->reference_value ) ?? 0;

				return $this->value;

			case ItemValueHandlerBase::OPERATION__IMPORT__PRESAVE:
				// Return a temporary and fake reference value.
				// This is required because the value coming from the import file is usually a full UUID,
				// which is not allowed for the integer local ID columns.
				// It's also required because if the referenced item is not yet imported,
				// we will not be able to get its local ID from the UUID.
				return 0;

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

	/**
	 * Call this to convert the value from a local ID to a UUID.
	 *
	 * Do not attempt to convert while all trackable items are not yet tracked (e.g. during plugin installation).
	 *
	 * @return string The UUID of the referenced item.
	 *
	 * @throws WpDeployException Error on conversion.
	 */
	private function getUuidFromLocalId(): string {
		// Replace the default value with the UUID of the referenced item.
		$uuid = $this->getReferencedItemTracker()->getItemUuidByLocalId( $this->value );

		if ( empty( $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.' ),
				$this->target_type,
				$this->value
			);

			throw new WpDeployException( $message );
		}

		// Replace the default value with the UUID of the referenced item, prefixed with the target type.
		return $uuid;
	}

	/**
	 * {@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 );

		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_value = null;

				return;
			}

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

		switch ( $origin ) {
			case ItemValueHandlerBase::ORIGIN__DATABASE:
				// The value is a local ID.
				$this->value = $value;
				break;

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

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

		// The full handler ID for reference item value handlers is "reference:<target_type>".
		$handler_id_parts = explode( ':', $field_config->getFullValueHandlerId() );
		$handler_id_parts = array_filter( array_map( 'trim', $handler_id_parts ) );

		if ( count( $handler_id_parts ) !== 2 ) {
			throw new WpDeployException( 'Invalid full handler ID.' );
		}

		$this->target_type = $handler_id_parts[1];
	}

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

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

		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;
	}
}
