Source: php/class-sync.php

<?php
/**
 * Sync manages all of the sync components for the Cloudinary plugin.
 *
 * @package Cloudinary
 */

namespace Cloudinary;

use Cloudinary\Component\Assets;
use Cloudinary\Component\Setup;
use Cloudinary\Settings\Setting;
use Cloudinary\Sync\Delete_Sync;
use Cloudinary\Sync\Download_Sync;
use Cloudinary\Sync\Push_Sync;
use Cloudinary\Sync\Sync_Queue;
use Cloudinary\Sync\Unsync;
use Cloudinary\Sync\Upload_Sync;
use WP_Error;

/**
 * Class Sync
 */
class Sync implements Setup, Assets {

	/**
	 * Holds the plugin instance.
	 *
	 * @since   0.1
	 *
	 * @var     Plugin Instance of the global plugin.
	 */
	public $plugin;

	/**
	 * Contains all the different sync components.
	 *
	 * @var Delete_Sync[]|Push_Sync[]|Upload_Sync[]|Media[]|Unsync[]|Download_Sync[]
	 */
	public $managers;

	/**
	 * Contains the sync base structure and callbacks.
	 *
	 * @var array
	 */
	protected $sync_base_struct;

	/**
	 * Contains the sync types and  callbacks.
	 *
	 * @var array
	 */
	protected $sync_types;

	/**
	 * Holds a list of unsynced images to push on end.
	 *
	 * @var array
	 */
	private $to_sync = array();

	/**
	 * Holds the settings slug.
	 *
	 * @var string
	 */
	public $settings_slug = 'sync_media';

	/**
	 * Holds the sync settings object.
	 *
	 * @var Setting
	 */
	public $settings;

	/**
	 * Holds the meta keys for sync meta to maintain consistency.
	 */
	const META_KEYS = array(
		'breakpoints'         => '_cloudinary_breakpoints',
		'bypass'              => '_bypass_delivery',
		'cloudinary'          => '_cloudinary',
		'cloudinary_legacy'   => '_cloudinary_v2',
		'dashboard_cache'     => '_cloudinary_dashboard_stats',
		'delay'               => '_cloudinary_sync_delay',
		'delivery'            => '_cloudinary_delivery',
		'downloading'         => '_cloudinary_downloading',
		'file_size'           => '_file_size',
		'folder_sync'         => '_folder_sync',
		'last_oversize_check' => '_last_oversize_check',
		'local_size'          => '_cld_local_size',
		'pending'             => '_cloudinary_pending',
		'plugin_version'      => '_plugin_version',
		'process_log'         => '_cloudinary_process_log',
		'process_log_legacy'  => '_process_log',
		'public_id'           => '_public_id',
		'queued'              => '_cloudinary_sync_queued',
		'relationship'        => '_cld_relationship',
		'remote_format'       => '_cld_remote_format',
		'remote_size'         => '_cld_remote_size',
		'signature'           => '_sync_signature',
		'storage'             => '_cloudinary_storage',
		'suffix'              => '_suffix',
		'sync_error'          => '_cld_error',
		'synced'              => '_cld_synced',
		'syncing'             => '_cloudinary_syncing',
		'transformation'      => '_transformations',
		'unsupported'         => '_cld_unsupported',
		'unsynced'            => '_cld_unsynced',
		'upgrading'           => '_cloudinary_upgrading',
		'version'             => '_cloudinary_version',
		'raw_url'             => '_cloudinary_url',
		'db_version'          => '_cloudinary_db_version',
		'debug'               => '_cloudinary_debug',
	);

	/**
	 * Holds the Sync Media option key.
	 */
	const SYNC_MEDIA = 'cloudinary_sync_media';

	/**
	 * Push_Sync constructor.
	 *
	 * @param Plugin $plugin Global instance of the main plugin.
	 */
	public function __construct( Plugin $plugin ) {
		$this->plugin               = $plugin;
		$this->managers['push']     = new Push_Sync( $this->plugin );
		$this->managers['upload']   = new Upload_Sync( $this->plugin );
		$this->managers['download'] = new Download_Sync( $this->plugin );
		$this->managers['delete']   = new Delete_Sync( $this->plugin );
		$this->managers['queue']    = new Sync_Queue( $this->plugin );
		$this->managers['unsync']   = new Unsync( $this->plugin );

		// Register Settings.
		add_filter( 'cloudinary_admin_pages', array( $this, 'settings' ) );
	}

	/**
	 * Setup assets/scripts.
	 */
	public function enqueue_assets() {
		if ( $this->plugin->settings->get_param( 'connected' ) ) {
			$data = array(
				'restUrl' => esc_url_raw( rest_url() ),
				'nonce'   => wp_create_nonce( 'wp_rest' ),
			);
			wp_add_inline_script( 'cloudinary', 'var cloudinaryApi = ' . wp_json_encode( $data ), 'before' );
		}
	}

	/**
	 * Register Assets.
	 */
	public function register_assets() {
		if ( $this->plugin->settings->get_param( 'connected' ) ) {
			// Setup the sync_base_structure.
			$this->setup_sync_base_struct();
			// Setup sync types.
			$this->setup_sync_types();
		}
	}

	/**
	 * Is the component Active.
	 */
	public function is_active() {
		return $this->settings && $this->settings->has_param( 'is_active' );
	}

	/**
	 * Checks if an asset has been synced and up to date.
	 *
	 * @param int $attachment_id The attachment id to check.
	 *
	 * @return bool
	 */
	public function been_synced( $attachment_id ) {
		$been = false;

		if ( $this->plugin->components['delivery']->is_deliverable( $attachment_id ) ) {
			$public_id = $this->managers['media']->has_public_id( $attachment_id );
			$meta      = wp_get_attachment_metadata( $attachment_id, true );
			$been      = ! empty( $public_id ) || ! empty( $meta['cloudinary'] ); // From v1.
		}

		return $been;
	}

	/**
	 * Checks if an asset is synced and up to date.
	 *
	 * @param int  $post_id  The post id to check.
	 * @param bool $re_check Flag to bypass cache and recheck.
	 *
	 * @return bool
	 */
	public function is_synced( $post_id, $re_check = false ) {
		static $synced = array();
		if ( isset( $synced[ $post_id ] ) && false === $re_check ) {
			return $synced[ $post_id ];
		}
		$return = false;
		if ( $this->managers['media']->has_public_id( $post_id ) && $this->been_synced( $post_id ) ) {
			$expecting = $this->generate_signature( $post_id );
			if ( ! is_wp_error( $expecting ) ) {
				$signature = $this->get_signature( $post_id );
				// Sort to align orders for comparison.
				ksort( $signature );
				ksort( $expecting );
				if ( ! empty( $signature ) && ! empty( $expecting ) && $expecting === $signature ) {
					$return = true;
				}
			}
		}
		$synced[ $post_id ] = $return;

		return $synced[ $post_id ];
	}

	/**
	 * Log a sync result.
	 *
	 * @param int    $attachment_id The attachment id.
	 * @param string $type          The sync type.
	 * @param mixed  $result        The result.
	 */
	public function log_sync_result( $attachment_id, $type, $result ) {
		$log  = $this->managers['media']->get_process_logs( $attachment_id, true );
		$keys = $this->sync_base_struct;
		if ( empty( $log ) || count( $log ) !== count( $keys ) ) {
			$missing_keys = array_diff_key( $keys, $log );
			$log          = array_merge(
				$log,
				array_fill_keys( array_keys( $missing_keys ), array() )
			);
		}
		if ( isset( $log[ $type ] ) ) {
			if ( is_wp_error( $result ) ) {
				$result = array(
					'code'    => $result->get_error_code(),
					'message' => $result->get_error_message(),
				);
				update_post_meta( $attachment_id, self::META_KEYS['sync_error'], $result['message'] );
			}

			$log[ $type ][ '_' . time() ] = $result;
			if ( 5 < count( $log[ $type ] ) ) {
				array_shift( $log[ $type ] );
			}
			update_post_meta( $attachment_id, self::META_KEYS['process_log'], $log );
		}
	}

	/**
	 * Check if sync type is required for rendering a Cloudinary URL.
	 *
	 * @param string|null $type          The type to check.
	 * @param int         $attachment_id The attachment ID.
	 *
	 * @return bool
	 */
	public function is_required( $type, $attachment_id ) {
		$return = false;
		if ( ! empty( $type ) && isset( $this->sync_base_struct[ $type ]['required'] ) ) {
			if ( is_callable( $this->sync_base_struct[ $type ]['required'] ) ) {
				$return = call_user_func( $this->sync_base_struct[ $type ]['required'], $attachment_id );
			} else {
				$return = $this->sync_base_struct[ $type ]['required'];
			}
		}

		return $return;
	}

	/**
	 * Generate a signature based on whats required for a full sync.
	 *
	 * @param int  $attachment_id The Attachment id to generate a signature for.
	 * @param bool $cache         Flag to specify if a cached signature is to be used or build a new one.
	 *
	 * @return string|bool
	 */
	public function generate_signature( $attachment_id, $cache = true ) {
		static $signatures = array(); // cache signatures.
		if ( ! empty( $signatures[ $attachment_id ] ) && true === $cache ) {
			$return = $signatures[ $attachment_id ];
		} else {
			$return = $this->sync_base( $attachment_id );
			// Add to signature cache.
			$signatures[ $attachment_id ] = $return;
		}

		return $return;
	}

	/**
	 * Check if an asset can be synced.
	 *
	 * @param int    $attachment_id The attachment ID to check if it  can be synced.
	 * @param string $type          The type of sync to attempt.
	 *
	 * @return bool
	 */
	public function can_sync( $attachment_id, $type = 'file' ) {

		$can = $this->is_auto_sync_enabled();

		if ( $this->is_pending( $attachment_id ) ) {
			$can = false;
		} elseif ( $this->been_synced( $attachment_id ) ) {
			$can = true;
		}

		if ( ! $this->managers['media']->is_uploadable_media( $attachment_id ) ) {
			$can = false;
		}

		// Can sync only syncable delivery types.
		if ( ! $this->is_syncable( $attachment_id ) ) {
			$can = false;
		}

		// Only sync deliverable attachments.
		if ( $can && ! $this->plugin->get_component( 'delivery' )->is_deliverable( $attachment_id ) ) {
			$can = false;
		}

		/**
		 * Filter to allow changing if an asset is allowed to be synced.
		 *
		 * @hook cloudinary_can_sync_asset
		 *
		 * @param $can           {bool} Can sync.
		 * @param $attachment_id {int} The attachment post ID.
		 * @param $type          {string} The type of sync to attempt.
		 *
		 * @return {bool}
		 */
		return apply_filters( 'cloudinary_can_sync_asset', $can, $attachment_id, $type );
	}

	/**
	 * Get the last version this asset was synced with.
	 *
	 * @param int $attachment_id The attachment ID.
	 *
	 * @return mixed
	 */
	public function get_sync_version( $attachment_id ) {
		$version = $this->managers['media']->get_post_meta( $attachment_id, self::META_KEYS['plugin_version'], true );

		return $version . '-' . $this->plugin->version;
	}

	/**
	 * Get the current sync signature of an asset.
	 *
	 * @param int  $attachment_id The attachment ID.
	 * @param bool $cached        Flag to specify if a cached signature is to be used or build a new one.
	 *
	 * @return array
	 */
	public function get_signature( $attachment_id, $cached = true ) {
		static $signatures = array(); // Cache signatures already fetched.

		$return = array();
		if ( ! empty( $signatures[ $attachment_id ] ) && true === $cached ) {
			$return = $signatures[ $attachment_id ];
		} else {
			$signature = $this->managers['media']->get_post_meta( $attachment_id, self::META_KEYS['signature'], true );
			if ( empty( $signature ) ) {
				$signature = array();
			}

			// Remove any old or outdated signature items. against the expected.
			$signature                    = array_intersect_key( $signature, $this->sync_types );
			$signatures[ $attachment_id ] = $return;
			$return                       = wp_parse_args( $signature, $this->sync_types );
		}

		/**
		 * Filter the get signature of the asset.
		 *
		 * @hook   cloudinary_get_signature
		 *
		 * @param $return        {array} The attachment signature.
		 * @param $attachment_id {int}   The attachment ID.
		 *
		 * @return {array}
		 */
		$return = apply_filters( 'cloudinary_get_signature', $return, $attachment_id );

		return $return;
	}

	/**
	 * Generate a new Public ID for an asset.
	 *
	 * @param int $attachment_id The attachment ID for the new public ID.
	 *
	 * @return string|null
	 */
	public function generate_public_id( $attachment_id ) {

		$cld_folder = $this->managers['media']->get_cloudinary_folder();
		if ( function_exists( 'wp_get_original_image_path' ) && wp_attachment_is_image( $attachment_id ) ) {
			$file = wp_get_original_image_path( $attachment_id );
		} else {
			$file = get_attached_file( $attachment_id );
		}
		$filename  = Utils::pathinfo( $file, PATHINFO_FILENAME );
		$public_id = $cld_folder . $filename;

		return ltrim( $public_id, '/' );
	}

	/**
	 * Is syncable asset.
	 *
	 * @param int $attachment_id The attachment ID.
	 *
	 * @return bool
	 */
	public function is_syncable( $attachment_id ) {
		$syncable = false;
		if (
			$this->managers['media']->is_media( $attachment_id )
			&& in_array(
				$this->managers['media']->get_media_delivery( $attachment_id ),
				$this->managers['media']->get_syncable_delivery_types(),
				true
			)
		) {
			$syncable = true;
		}

		if ( true === $syncable && $this->managers['media']->is_oversize_media( $attachment_id ) ) {
			$syncable = false;
		}

		return $syncable;
	}

	/**
	 * Register a new sync type.
	 *
	 * @param string $type      Sync type key. Must not exceed 20 characters and may
	 *                          only contain lowercase alphanumeric characters, dashes,
	 *                          and underscores. See sanitize_key().
	 * @param array  $structure {
	 *                          Array of arguments for registering a sync type.
	 *
	 * @type   callable      $generate  Callback method that generates the values to be used to sign a state.
	 *                                    Returns a string or array.
	 *
	 * @type   callable      $validate  Optional Callback method that validates the need to have the sync type applied.
	 *                                    returns Bool.
	 *
	 * @type   int           $priority  Priority in which the type takes place. Lower is higher priority.
	 *                                    i.e a download should happen before an upload so download is lower in the chain.
	 *
	 * @type   callable      $sync      Callback method that handles the sync. i.e uploads the file, adds meta data, etc..
	 *
	 * @type   string        $state     State class to be added to the status icon in media library.
	 *
	 * @type string|callback $note      The status text displayed next to a syncing asset in the media library.
	 *                                    Can be a callback if the note needs to be dynamic. see type folder.
	 *
	 * }
	 */
	public function register_sync_type( $type, $structure ) {

		// Apply a default to ensure parts exist.
		$default = array(
			'generate'    => '__return_null',
			'validate'    => null,
			'priority'    => 50,
			'sync'        => '__return_null',
			'state'       => 'sync',
			'note'        => __( 'Synchronizing asset with Cloudinary', 'cloudinary' ),
			'asset_state' => 1,
		);

		$this->sync_base_struct[ $type ] = wp_parse_args( $structure, $default );
	}

	/**
	 * Get built-in structures that form an assets entire sync state. This holds methods for building signatures for each state of synchronization.
	 * These can be extended via 3rd parties by adding to the structures with custom types and generation and sync methods.
	 */
	public function setup_sync_base_struct() {

		$base_struct = array(
			'upgrade'      => array(
				'generate' => array( $this, 'get_sync_version' ), // Method to generate a signature.
				'validate' => array( $this, 'been_synced' ),
				'priority' => 0.2,
				'sync'     => array( $this->managers['media']->upgrade, 'convert_cloudinary_version' ),
				'state'    => 'info syncing',
				'note'     => __( 'Upgrading from previous version', 'cloudinary' ),
				'realtime' => true,
			),
			'download'     => array(
				'generate' => '__return_false',
				'validate' => function ( $attachment_id ) {
					return (bool) $this->managers['media']->get_post_meta( $attachment_id, self::META_KEYS['upgrading'], true );
				},
				'priority' => 1,
				'sync'     => array( $this->managers['download'], 'download_asset' ),
				'state'    => 'info downloading',
				'note'     => __( 'Downloading from Cloudinary', 'cloudinary' ),
			),
			'file'         => array(
				'asset_state' => 0,
				'generate'    => array( $this, 'generate_file_signature' ),
				'priority'    => 5.1,
				'sync'        => array( $this->managers['upload'], 'upload_asset' ),
				'validate'    => function ( $attachment_id ) {
					$valid = ! $this->managers['media']->has_public_id( $attachment_id ) && ! $this->managers['media']->is_oversize_media( $attachment_id );
					if ( $valid ) {
						$valid = $this->plugin->get_component( 'delivery' )->is_deliverable( $attachment_id );
					}
					return $valid;
				},
				'state'       => 'uploading',
				'note'        => __( 'Uploading to Cloudinary', 'cloudinary' ),
				'required'    => true, // Required to complete URL render flag.
			),
			'edit'         => array(
				'generate' => array( $this, 'generate_edit_signature' ),
				'priority' => 5.2,
				'sync'     => array( $this->managers['upload'], 'edit_upload' ),
				'validate' => function ( $attachment_id ) {
					$valid  = false;
					$backup = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true );
					if ( ! empty( $backup ) ) {
						$valid = true;
					}

					return $valid;
				},
				'state'    => 'uploading',
				'note'     => __( 'Uploading to Cloudinary', 'cloudinary' ),
				'required' => false, // Required to complete URL render flag.
				'realtime' => true,
			),
			'folder'       => array(
				'generate' => array( $this->managers['media'], 'get_cloudinary_folder' ),
				'validate' => array( $this->managers['media'], 'is_folder_synced' ),
				'priority' => 10,
				'sync'     => array( $this->managers['upload'], 'upload_asset' ),
				'state'    => 'info syncing',
				'note'     => function () {
					return sprintf(
					/* translators: %s folder name */
						__( 'Copying to folder %s.', 'cloudinary' ),
						untrailingslashit( $this->managers['media']->get_cloudinary_folder() )
					);
				},
			),
			'public_id'    => array(
				'generate' => array( $this->managers['media'], 'get_public_id' ),
				'validate' => function ( $attachment_id ) {
					$public_id = $this->managers['media']->has_public_id( $attachment_id );

					return false === $public_id;
				},
				'priority' => 20,
				'sync'     => array( $this->managers['media']->upgrade, 'convert_cloudinary_version' ), // Rename.
				'state'    => 'info syncing',
				'note'     => __( 'Updating metadata', 'cloudinary' ),
				'required' => true,
			),
			'breakpoints'  => array(
				'generate' => array( $this->managers['media'], 'get_breakpoint_options' ),
				'priority' => 25,
				'sync'     => array( $this->managers['upload'], 'explicit_update' ),
				'validate' => function ( $attachment_id ) {
					$delivery = $this->managers['media']->get_post_meta( $attachment_id, self::META_KEYS['delivery'], true );

					return empty( $delivery ) || 'upload' === $delivery;
				},
				'state'    => 'info syncing',
				'note'     => __( 'Updating breakpoints', 'cloudinary' ),
			),
			'options'      => array(
				'asset_state' => 0,
				'generate'    => function ( $attachment_id ) {
					$options = $this->managers['media']->get_upload_options( $attachment_id );
					unset( $options['eager'], $options['eager_async'] );

					return $options;
				},
				'validate'    => array( $this, 'been_synced' ),
				'priority'    => 30,
				'sync'        => array( $this->managers['upload'], 'context_update' ),
				'state'       => 'info syncing',
				'note'        => __( 'Updating metadata', 'cloudinary' ),
			),
			'cloud_name'   => array(
				'generate' => array( $this->managers['connect'], 'get_cloud_name' ),
				'validate' => function ( $attachment_id ) {

					$valid       = true;
					$credentials = $this->managers['connect']->get_credentials();
					if ( isset( $credentials['cname'] ) ) {
						$url = get_post_meta( $attachment_id, '_wp_attached_file', true );
						if ( wp_http_validate_url( $url ) ) {
							$domain = wp_parse_url( $url, PHP_URL_HOST );
							$valid  = $domain !== $credentials['cname'];
						}
					}

					return $valid;
				},
				'priority' => 5.5,
				'sync'     => array( $this->managers['upload'], 'upload_asset' ),
				'state'    => 'uploading',
				'note'     => __( 'Uploading to new cloud name.', 'cloudinary' ),
				'required' => true,
			),
			'meta_cleanup' => array(
				'generate' => function ( $attachment_id ) {
					$meta = $this->managers['media']->get_post_meta( $attachment_id );

					$return = false;
					foreach ( $meta as $key => $value ) {
						if ( get_post_meta( $attachment_id, $key, true ) === $value ) {
							$return = true;
							break;
						}
					}

					return $return;
				},
				'priority' => 100, // Always be the highest.
				'sync'     => function ( $attachment_id ) {
					$meta         = $this->managers['media']->get_post_meta( $attachment_id );
					$cleanup_keys = array(
						self::META_KEYS['cloudinary'],
						self::META_KEYS['upgrading'],
					);
					foreach ( $meta as $key => $value ) {
						if ( in_array( $key, $cleanup_keys, true ) ) {
							$this->managers['media']->delete_post_meta( $attachment_id, $key );
							continue;
						}
						delete_post_meta( $attachment_id, $key, $value );
					}
					$this->set_signature_item( $attachment_id, 'meta_cleanup' );
				},
				'required' => true,
				'realtime' => true,
			),
		);

		/**
		 * Filter the sync base structure to allow other plugins to sync component callbacks.
		 *
		 * @hook cloudinary_sync_base_struct
		 *
		 * @param $base_struct {array} The base sync structure.
		 *
		 * @return {array}
		 */
		$base_struct = apply_filters( 'cloudinary_sync_base_struct', $base_struct );

		// Register each sync type.
		foreach ( $base_struct as $type => $structure ) {
			$this->register_sync_type( $type, $structure );
		}

		/**
		 * Do action for setting up sync types.
		 *
		 * @hook cloudinary_register_sync_types
		 *
		 * @param $this {Sync} The sync object.
		 */
		do_action( 'cloudinary_register_sync_types', $this );
	}

	/**
	 * Setup the sync types in priority order based on sync struct.
	 */
	public function setup_sync_types() {

		$sync_types = array();
		foreach ( $this->sync_base_struct as $type => $struct ) {
			if ( is_callable( $struct['sync'] ) ) {
				$sync_types[ $type ] = floatval( $struct['priority'] );
			}
		}

		asort( $sync_types );

		$this->sync_types = $sync_types;
	}

	/**
	 * Get a method from a sync type.
	 *
	 * @param string $type   The sync type to get from.
	 * @param string $method The method to get from the sync type.
	 *
	 * @return callable|null
	 */
	public function get_sync_type_method( $type, $method ) {
		$return = null;
		if ( isset( $this->sync_base_struct[ $type ][ $method ] ) && is_callable( $this->sync_base_struct[ $type ][ $method ] ) ) {
			$return = $this->sync_base_struct[ $type ][ $method ];
		}

		return $return;
	}

	/**
	 * Run a sync method on and attachment_id.
	 *
	 * @param string $type          The sync type to run.
	 * @param string $method        The method to run.
	 * @param int    $attachment_id The attachment ID to run method against.
	 *
	 * @return mixed
	 */
	public function run_sync_method( $type, $method, $attachment_id ) {
		$return     = null;
		$run_method = $this->get_sync_type_method( $type, $method );
		if ( $run_method ) {
			$return = call_user_func( $run_method, $attachment_id );
		}

		return $return;
	}

	/**
	 * Generate a single sync type signature for an asset.
	 *
	 * @param string $type          The sync type to run.
	 * @param int    $attachment_id The attachment ID to run method against.
	 *
	 * @return mixed
	 */
	public function generate_type_signature( $type, $attachment_id ) {
		$return     = null;
		$run_method = $this->get_sync_type_method( $type, 'generate' );
		if ( $run_method ) {
			$value = call_user_func( $run_method, $attachment_id );
			if ( ! is_wp_error( $value ) ) {
				if ( is_array( $value ) ) {
					$value = wp_json_encode( $value );
				}
				$return = md5( $value );
			}
		}

		return $return;
	}

	/**
	 * Get the asset state ( sync level ) of an attachment.
	 *
	 * @param int $attachment_id The attachment ID to get.
	 *
	 * @return int
	 */
	public function get_asset_state( $attachment_id ) {

		$state = $this->been_synced( $attachment_id ) ? 1 : 0;

		/**
		 * Filter the state of the asset.
		 *
		 * @hook   cloudinary_asset_state
		 *
		 * @param $state         {int} The attachment state.
		 * @param $attachment_id {int}   The attachment ID.
		 *
		 * @return {array}
		 */
		return apply_filters( 'cloudinary_asset_state', $state, $attachment_id );
	}

	/**
	 * Prepares and asset for sync comparison by getting all sync types
	 * and running the generate methods for each type.
	 *
	 * @param int $attachment_id Attachment ID to prepare.
	 *
	 * @return array
	 */
	public function sync_base( $attachment_id ) {

		$return      = array();
		$asset_state = $this->get_asset_state( $attachment_id );
		foreach ( array_keys( $this->sync_types ) as $type ) {
			if ( $asset_state >= $this->sync_base_struct[ $type ]['asset_state'] ) {
				$return[ $type ] = $this->generate_type_signature( $type, $attachment_id );
			}
		}

		/**
		 * Filter the sync base to allow other plugins to add requested sync components for the sync signature.
		 *
		 * @hook   cloudinary_sync_base
		 *
		 * @param $signatures {array}   The attachments required signatures.
		 * @param $post       {WP_Post} The attachment post.
		 * @param $sync       {Sync}    The sync object instance.
		 *
		 * @return {array}
		 */
		$return = apply_filters( 'cloudinary_sync_base', $return, get_post( $attachment_id ), $this );

		return $return;
	}

	/**
	 * Prepare an asset to be synced, maybe.
	 *
	 * @param int $attachment_id The attachment ID.
	 *
	 * @return string | null
	 */
	public function maybe_prepare_sync( $attachment_id ) {
		$type = $this->get_sync_type( $attachment_id );
		$can  = $this->can_sync( $attachment_id, $type );
		if ( $type && true === $can ) {
			$this->add_to_sync( $attachment_id );
		}

		return $type;
	}

	/**
	 * Get the type of sync, with the lowest priority for this asset.
	 *
	 * @param int  $attachment_id The attachment ID.
	 * @param bool $cached        Flag to specify if a cached signature is to be used or build a new one.
	 *
	 * @return string|null
	 */
	public function get_sync_type( $attachment_id, $cached = true ) {
		if ( ! $this->managers['media']->is_media( $attachment_id ) || ! empty( get_post_meta( $attachment_id, self::META_KEYS['sync_error'], true ) ) ) {
			return null; // Ignore non media items or errored syncs.
		}
		$return               = null;
		$required_signature   = $this->generate_signature( $attachment_id, $cached );
		$attachment_signature = $this->get_signature( $attachment_id, $cached );
		$attachment_signature = array_intersect_key( $attachment_signature, $required_signature );
		if ( is_array( $required_signature ) ) {
			$sync_items = array_filter(
				$attachment_signature,
				function ( $item, $key ) use ( $required_signature ) {
					return $item !== $required_signature[ $key ];
				},
				ARRAY_FILTER_USE_BOTH
			);
			$ordered    = array_intersect_key( $this->sync_types, $sync_items );
			if ( ! empty( $ordered ) ) {
				$types  = array_keys( $ordered );
				$type   = array_shift( $types );
				$return = $this->validate_sync_type( $type, $attachment_id );
			}
		}

		return $return;
	}

	/**
	 * Validate the asset needs the sync type to be run, and generate a valid signature if not.
	 *
	 * @param string $type          The sync type to validate.
	 * @param int    $attachment_id The attachment ID to validate against.
	 *
	 * @return string|null
	 */
	public function validate_sync_type( $type, $attachment_id ) {
		// Validate that this sync type applied (for optional types like upgrade).
		if ( false === $this->run_sync_method( $type, 'validate', $attachment_id ) ) {
			// If invalid, save the new signature.
			$this->set_signature_item( $attachment_id, $type );

			$type = $this->get_sync_type( $attachment_id, false ); // Set cache to false to get the new signature.

			// Check if this is a realtime process.
		} elseif ( ! empty( $this->sync_base_struct[ $type ]['realtime'] ) ) {
			$result = $this->run_sync_method( $type, 'sync', $attachment_id );
			if ( ! empty( $result ) ) {
				$this->log_sync_result( $attachment_id, $type, $result );
			}
			$type = $this->get_sync_type( $attachment_id, false ); // Set cache to false to get the new signature.
		}

		return $type;
	}

	/**
	 * Checks the status of the media item.
	 *
	 * @param array $status        Array of state and note.
	 * @param int   $attachment_id The attachment id.
	 *
	 * @return array
	 */
	public function filter_status( $status, $attachment_id ) {

		if ( $this->been_synced( $attachment_id ) || ( $this->is_pending( $attachment_id ) && $this->get_sync_type( $attachment_id ) ) ) {
			$sync_type = $this->get_sync_type( $attachment_id );
			if ( ! empty( $sync_type ) && isset( $this->sync_base_struct[ $sync_type ] ) ) {
				// check process log in case theres an error.
				$log = $this->managers['media']->get_process_logs( $attachment_id );
				if ( ! empty( $log[ $sync_type ] ) && is_wp_error( $log[ $sync_type ] ) ) {
					// Use error instead of sync note.
					$status['state'] = 'error';
					$status['note']  = $log[ $sync_type ]->get_error_message();
				} else {
					$status['state'] = $this->sync_base_struct[ $sync_type ]['state'];
					$status['note']  = $this->sync_base_struct[ $sync_type ]['note'];
					if ( is_callable( $status['note'] ) ) {
						$status['note'] = call_user_func( $status['note'], $attachment_id );
					}
				}
			}

			// Check if there's an error.
			$has_error = get_post_meta( $attachment_id, self::META_KEYS['sync_error'], true );
			if ( ! empty( $has_error ) && $this->get_sync_type( $attachment_id ) ) {
				$status['state'] = 'error';
				$status['note']  = $has_error;
			}
		}

		return $status;
	}

	/**
	 * Add media state to display syncing info.
	 *
	 * @param array    $media_states List of the states.
	 * @param \WP_Post $post         The current attachment post.
	 *
	 * @return array
	 */
	public function filter_media_states( $media_states, $post ) {

		/**
		 * Filter the Cloudinary media status.
		 *
		 * @hook cloudinary_media_status
		 * @default array()
		 *
		 * @param $status  {array} The media status.
		 * @param $post_id {int} The Post ID.
		 *
		 * @return {array}
		 */
		$status = apply_filters( 'cloudinary_media_status', array(), $post->ID );
		if ( ! empty( $status ) ) {
			$media_states[] = $status['note'];
		}

		return $media_states;
	}

	/**
	 * Check if the attachment is pending an upload sync.
	 *
	 * @param int $attachment_id The attachment ID to check.
	 *
	 * @return bool
	 */
	public function is_pending( $attachment_id ) {
		// Check if it's not already in the to sync array.
		if ( ! in_array( $attachment_id, $this->to_sync, true ) ) {
			$is_pending = get_post_meta( $attachment_id, self::META_KEYS['pending'], true );
			if ( empty( $is_pending ) || $is_pending < time() - 5 * 60 ) {
				// No need to delete pending meta, since it will be updated with the new timestamp anyway.
				return false;
			}
		}

		return true;
	}

	/**
	 * Set an attachment as pending.
	 *
	 * @param int $attachment_id The attachment ID to set as pending.
	 */
	public function set_pending( $attachment_id ) {
		update_post_meta( $attachment_id, self::META_KEYS['pending'], time() );
	}

	/**
	 * Add an attachment ID to the to_sync array.
	 *
	 * @param int $attachment_id The attachment ID to add.
	 */
	public function add_to_sync( $attachment_id ) {
		if ( ! in_array( (int) $attachment_id, $this->to_sync, true ) ) {

			// There are cases where we do not check can_sync. This is to make sure we don't add to the to_sync array if we can't sync.
			if ( ! $this->plugin->get_component( 'delivery' )->is_deliverable( $attachment_id ) ) {
				return;
			}

			// Flag image as pending to prevent duplicate upload.
			$this->set_pending( $attachment_id );
			$this->to_sync[] = $attachment_id;
		}
	}

	/**
	 * Update signatures of types that match the specified types sync method. This prevents running the same method repeatedly.
	 *
	 * @param int    $attachment_id The attachment ID.
	 * @param string $type          The type of sync.
	 */
	public function sync_signature_by_type( $attachment_id, $type ) {
		$current_sync_method = $this->sync_base_struct[ $type ]['sync'];

		// Go over all other types that share the same sync method and include them here.
		foreach ( $this->sync_base_struct as $sync_type => $struct ) {
			if ( $struct['sync'] === $current_sync_method ) {
				$this->set_signature_item( $attachment_id, $sync_type );
			}
		}
	}

	/**
	 * Set an item to the signature set.
	 *
	 * @param int    $attachment_id The attachment ID.
	 * @param string $type          The sync type.
	 * @param null   $value         The value.
	 */
	public function set_signature_item( $attachment_id, $type, $value = null ) {

		// Get the core meta.
		$meta = $this->managers['media']->get_post_meta( $attachment_id, self::META_KEYS['signature'], true );
		if ( empty( $meta ) ) {
			$meta = array();
		}
		// Set the specific value.
		if ( is_null( $value ) ) {
			// Generate a new value based on generator.
			$value = $this->generate_type_signature( $type, $attachment_id );
		}
		$meta[ $type ] = $value;
		$this->managers['media']->update_post_meta( $attachment_id, self::META_KEYS['signature'], $meta );
	}

	/**
	 * Initialize the background sync on requested images needing to be synced.
	 */
	public function init_background_upload() {
		if ( ! empty( $this->to_sync ) ) {
			$this->managers['queue']->add_to_queue( $this->to_sync, 'autosync' );
			$this->managers['queue']->start_threads( 'autosync' );
		}
	}

	/**
	 * Filter the Cloudinary Folder.
	 *
	 * @param string $value The set folder.
	 * @param string $slug  The setting slug.
	 *
	 * @return string
	 */
	public function filter_get_cloudinary_folder( $value, $slug ) {
		if ( '.' === $value && 'cloudinary_folder' === $slug ) {
			$value = '';
		}

		return $value;
	}

	/**
	 * Filter the signature.
	 *
	 * @param array $signature     The signature array.
	 * @param int   $attachment_id The attachment ID.
	 *
	 * @return array|bool|string|WP_Error
	 */
	public function get_signature_syncable_type( $signature, $attachment_id ) {

		if ( ! $this->is_syncable( $attachment_id ) ) {
			$signature = $this->generate_signature( $attachment_id );
		}

		return $signature;
	}

	/**
	 * Checks if auto sync feature is enabled.
	 *
	 * @return bool
	 */
	public function is_auto_sync_enabled() {

		if ( 'on' === $this->plugin->settings->get_value( 'auto_sync' ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Delete Cloudinary meta for the attachment ID.
	 *
	 * @param int $attachment_id The attachment ID.
	 */
	public function delete_cloudinary_meta( $attachment_id ) {
		// Update attachment meta.
		$meta = wp_get_attachment_metadata( $attachment_id, true );
		if ( ! empty( $meta[ self::META_KEYS['cloudinary'] ] ) ) {
			unset( $meta[ self::META_KEYS['cloudinary'] ] );
		}
		wp_update_attachment_metadata( $attachment_id, $meta );

		// Cleanup postmeta.
		$queued = get_post_meta( $attachment_id, self::META_KEYS['queued'] );
		delete_post_meta( $attachment_id, self::META_KEYS['sync_error'] );
		delete_post_meta( $attachment_id, self::META_KEYS['pending'] );
		delete_post_meta( $attachment_id, self::META_KEYS['queued'] );
		delete_post_meta( $attachment_id, self::META_KEYS['suffix'] );
		delete_post_meta( $attachment_id, self::META_KEYS['public_id'] );
		delete_post_meta( $attachment_id, $queued );

		// Signatures cleanup.
		$signatures = $this->get_signature( $attachment_id );
		foreach ( $signatures as $signature ) {
			delete_post_meta( $attachment_id, "_{$signature}" );
		}

		// Delete main meta.
		delete_post_meta( $attachment_id, self::META_KEYS['cloudinary'] );
	}

	/**
	 * Register the `is_cloudinary_synced` rest field.
	 *
	 * @return void
	 */
	public function rest_api_is_synced_field() {
		register_rest_field(
			'attachment',
			'is_cloudinary_synced',
			array(
				'get_callback' => function ( $attachment ) {
					return $this->is_synced( $attachment['id'] );
				},
				'schema'       => array(
					'description' => __( 'Is Cloudinary synced.', 'cloudinary' ),
					'type'        => 'bool',
				),
			)
		);
	}

	/**
	 * Additional component setup.
	 */
	public function setup() {

		if ( $this->plugin->settings->get_param( 'connected' ) ) {

			// Show sync status.
			add_filter( 'cloudinary_media_status', array( $this, 'filter_status' ), 10, 2 );
			add_filter( 'display_media_states', array( $this, 'filter_media_states' ), 10, 2 );
			// Hook for on demand upload push.
			add_action( 'shutdown', array( $this, 'init_background_upload' ) );

			$this->managers['upload']->setup();
			$this->managers['delete']->setup();
			$this->managers['download']->setup();
			$this->managers['push']->setup();
			$this->managers['unsync']->setup();
			// Setup additional components.
			$this->managers['media']   = $this->plugin->components['media'];
			$this->managers['connect'] = $this->plugin->components['connect'];
			$this->managers['api']     = $this->plugin->components['api'];

			// Setup sync queue.
			$this->managers['queue']->setup( $this );

			add_filter( 'cloudinary_setting_get_value', array( $this, 'filter_get_cloudinary_folder' ), 10, 2 );
			add_filter( 'cloudinary_get_signature', array( $this, 'get_signature_syncable_type' ), 10, 2 );

			add_action( 'rest_api_init', array( $this, 'rest_api_is_synced_field' ) );
		}
	}

	/**
	 * Define the settings on the general settings page.
	 *
	 * @param array $pages The pages to add to.
	 *
	 * @return array
	 */
	public function settings( $pages ) {

		$pages['connect']['settings'][] = array(
			array(
				'type'                => 'frame',
				'requires_connection' => true,
				array(
					'type'        => 'panel',
					'title'       => __( 'Media Library Sync Settings', 'cloudinary' ),
					'option_name' => 'sync_media',
					'collapsible' => 'open',
					array(
						'type'         => 'radio',
						'title'        => __( 'Sync method', 'cloudinary' ),
						'tooltip_text' => sprintf(
							// translators: The HTML break line, the link to Cloudinary documentation and closing tag.
							__( 'Defines how your WordPress assets sync with Cloudinary.%1$s“Auto” will sync assets automatically.%2$s“Manual” requires triggering via the WordPress Media Library.%3$s%4$sLearn more%5$s', 'cloudinary' ),
							'<ul><li>',
							'</li><li>',
							'</li></ul>',
							'<a href="https://cloudinary.com/documentation/wordpress_integration#syncing_media" target="_blank" rel="noopener noreferrer">',
							'</a>'
						),
						'slug'         => 'auto_sync',
						'no_cached'    => true,
						'default'      => 'on',
						'options'      => array(
							'on'  => __( 'Auto sync', 'cloudinary' ),
							'off' => __( 'Manual sync', 'cloudinary' ),
						),
					),
					array(
						'type'              => 'text',
						'slug'              => 'cloudinary_folder',
						'title'             => __( 'Cloudinary folder path', 'cloudinary' ),
						'default'           => '',
						'attributes'        => array(
							'input' => array(
								'placeholder' => __( 'e.g.: wordpress_assets/', 'cloudinary' ),
							),
						),
						'tooltip_text'      => __(
							'The folder in your Cloudinary account that WordPress assets are uploaded to. Leave blank to use the root of your Cloudinary library.',
							'cloudinary'
						),
						'sanitize_callback' => array( '\Cloudinary\Media', 'sanitize_cloudinary_folder' ),
					),
					array(
						'type'         => 'select',
						'slug'         => 'offload',
						'title'        => __( 'Storage', 'cloudinary' ),
						'tooltip_text' => sprintf(
							// translators: the HTML for opening and closing list and its items.
							__(
								'Choose where your assets are stored.%1$sCloudinary and WordPress: Stores assets in both locations. Enables local WordPress delivery if the Cloudinary plugin is disabled or uninstalled.%2$sCloudinary and WordPress (low resolution):  Stores original assets in Cloudinary and low resolution versions in WordPress. Enables low resolution local WordPress delivery if the plugin is disabled or uninstalled.%3$sCloudinary only: Stores assets in Cloudinary only. Requires additional steps to enable backwards compatibility.%4$s%5$sLearn more%6$s',
								'cloudinary'
							),
							'<ul><li class="dual_full">',
							'</li><li class="dual_low">',
							'</li><li class="cld">',
							'</li></ul>',
							'<a href="https://cloudinary.com/documentation/wordpress_integration#storage" target="_blank" rel="noopener noreferrer">',
							'</a>'
						),
						'default'      => 'dual_full',
						'options'      => array(
							'dual_full' => __( 'Cloudinary and WordPress', 'cloudinary' ),
							'dual_low'  => __( 'Cloudinary and WordPress (low resolution)', 'cloudinary' ),
							'cld'       => __( 'Cloudinary only', 'cloudinary' ),
						),
					),
					array(
						'type'  => 'info_box',
						'icon'  => $this->plugin->dir_url . 'css/images/academy-icon.svg',
						'title' => __( 'Need help?', 'cloudinary' ),
						'text'  => sprintf(
							// Translators: The HTML for opening and closing link tags.
							__(
								'Watch free lessons on how to use the Media Library Sync Settings in the %1$sCloudinary Academy%2$s.',
								'cloudinary'
							),
							'<a href="https://training.cloudinary.com/learn/course/introduction-to-cloudinary-for-wordpress-administrators-70-minute-course-1h85/lessons/uploading-and-syncing-cloudinary-assets-1624?page=1" target="_blank" rel="noopener noreferrer">',
							'</a>'
						),
					),
				),
			),
		);

		return $pages;
	}

	/**
	 * Generate the real file attachment path for the file sync type signature.
	 *
	 * @param int $attachment_id The attachment ID.
	 *
	 * @return string
	 */
	public function generate_file_signature( $attachment_id ) {
		$path = get_attached_file( $attachment_id );
		$str  = ! $this->managers['media']->is_oversize_media( $attachment_id ) ? wp_basename( $path ) : $attachment_id;

		return $str . $this->is_auto_sync_enabled();
	}

	/**
	 * Generate a signature for a file edit.
	 *
	 * @param int $attachment_id The attachment ID.
	 *
	 * @return string
	 */
	public function generate_edit_signature( $attachment_id ) {
		$backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true );
		$path         = get_attached_file( $attachment_id );
		$str          = wp_basename( $path );
		if ( ! empty( $backup_sizes ) ) {
			$str .= wp_json_encode( $backup_sizes );
		}

		return $str;
	}
}