<?php
/**
 * Distributor post object abstraction.
 *
 * @package  distributor
 */

namespace Distributor;

use Distributor\Utils;
use WP_Post;

/**
 * This is the post abstraction for distributor posts.
 *
 * Although this class is within the Distributor namespace, the class itself
 * includes the phrase Distributor to make it clear to developers `use`ing
 * the class that they are not using the `WP_Post` object.
 *
 * Developer note: This class uses the `__call()` magic method to ensure the
 * post data is from the correct site on multisite installs. This is to avoid
 * repeating code to determine if `switch_to_blog()` is required.
 *
 * When adding new methods to this class, please ensure that the method is
 * protected to ensure the magic method is used. If the method is intended to
 * be public, please add it to the `@method` docblock below to ensure it is
 * shown in IDEs.
 *
 * @since 2.0.0
 *
 * @method bool has_blocks()
 * @method bool has_block( string $block_name )
 * @method int  get_the_ID()
 * @method string get_permalink()
 * @method string get_post_type()
 * @method int|false get_post_thumbnail_id()
 * @method string|false get_post_thumbnail_url( string $size = 'post-thumbnail' )
 * @method string|false get_the_post_thumbnail( string $size = 'post-thumbnail', array $attr = '' )
 * @method string get_canonical_url( string $canonical_url = '' )
 * @method string get_author_name( string $author_name = '' )
 * @method string get_author_link( string $author_link = '' )
 * @method array get_meta()
 * @method array get_terms()
 * @method array get_media()
 * @method array get_extra_data()
 * @method array post_data()
 * @method array to_insert( array $args = [] )
 * @method array to_pull_list( array $args = [] )
 * @method array to_rest( array $args = [] )
 * @method array parse_blocks_for_attribute_values( string $block, string $block_name, string|array $block_attribute )
 * @method array get_shortcode_attribute_values( string $content, string $shortcode, string|array $shortcode_attribute )
 */
class DistributorPost {
	/**
	 * The WordPress post object.
	 *
	 * @since 2.0.0
	 * @var WP_Post
	 */
	public $post = false;

	/**
	 * Whether this is the source (true) or a distributed post (false).
	 *
	 * @since 2.0.0
	 * @var bool
	 */
	public $is_source = true;

	/**
	 * Whether this post is linked to the original version.
	 *
	 * For the original post this is set to true.
	 *
	 * @since 2.0.0
	 * @var bool
	 */
	public $is_linked = true;

	/**
	 * The original post ID.
	 *
	 * @since 2.0.0
	 * @var int
	 */
	public $original_post_id = 0;

	/**
	 * The original post's URL.
	 *
	 * This is marked private but can be accessed via the `__get` method to allow
	 * for live updates of the original post URL for internal connections.
	 *
	 * @since 2.0.0
	 * @var string
	 */
	private $original_post_url = '';

	/**
	 * Whether the original post has been deleted.
	 *
	 * @since 2.0.0
	 * @var bool
	 */
	public $original_deleted = false;

	/**
	 * The direction of the connection.
	 *
	 * Internal connections are identical regardless of whether they are pushed or pulled
	 * so are considered bidirectional.
	 *
	 * @since 2.0.0
	 * @var string pushed|pulled|bidirectional|empty
	 */
	public $connection_direction = '';

	/**
	 * The type of connection this post is distributed from.
	 *
	 * @since 2.0.0
	 * @var string internal|external|empty (for source)
	 */
	public $connection_type = '';

	/**
	 * The connection ID this post is distributed from.
	 *
	 * For internal connections this is the site ID. For external connections
	 * this refers to the connection ID.
	 *
	 * @since 2.0.0
	 * @var int|string
	 */
	public $connection_id = 0;

	/**
	 * The site ID of this post.
	 *
	 * @since 2.0.0
	 * @var int
	 */
	public $site_id = 0;

	/**
	 * The source site data for internal connections.
	 *
	 * This is an array of site data for the source site. This is set by
	 * the populate_source_site() method upon access to avoid switching
	 * sites unnecessarily.
	 *
	 * @since 2.0.0
	 * @var array {
	 *    @type string $home_url The site's home page.
	 *    @type string $site_url The site's WordPress address.
	 *    @type string $rest_url The site's REST API address.
	 *    @type string $name     The site name.
	 * }
	 */
	private $source_site = [];

	/**
	 * The cache for accessing methods when the site has been switched.
	 *
	 * This prevents the need to switch sites multiple times when accessing
	 * the same method multiple times.
	 *
	 * @since 2.0.0
	 * @var array
	 */
	private $switched_site_cache = [];

	/**
	 * Initialize the DistributorPost object.
	 *
	 * @since 2.0.0
	 *
	 * @param WP_Post|int $post WordPress post object or post ID.
	 */
	public function __construct( $post ) {
		$post = get_post( $post );

		if ( ! $post ) {
			return;
		}

		$this->site_id = get_current_blog_id();
		$this->post    = $post;

		/*
		 * The original post ID is listed as excluded post meta and therefore
		 * unavailable in the meta property. We need to get it using the post
		 * meta API.
		 */
		$original_post_id = get_post_meta( $post->ID, 'dt_original_post_id', true );
		if ( empty( $original_post_id ) ) {
			// This is the source post.
			$this->is_source         = true;
			$this->is_linked         = true;
			$this->original_post_id  = $this->post->ID;
			$this->original_post_url = get_permalink( $this->post->ID );
			$this->original_deleted  = false;
			return;
		}

		// Set up information for a distributed post.
		$this->is_source = false;
		// Reverse value of the `dt_unlinked` meta data.
		$this->is_linked         = ! get_post_meta( $post->ID, 'dt_unlinked', true );
		$this->original_post_id  = (int) $original_post_id;
		$this->original_post_url = get_post_meta( $post->ID, 'dt_original_post_url', true );
		$this->original_deleted  = (bool) get_post_meta( $post->ID, 'dt_original_deleted', true );

		// Determine the connection type.
		if ( get_post_meta( $post->ID, 'dt_original_blog_id', true ) ) {
			/*
			 * Internal connections store the original blog's ID.
			 *
			 * Pushed and pulled posts are indistinguishable from each other.
			 */
			$this->connection_type      = 'internal';
			$this->connection_direction = 'bidirectional';
			$this->connection_id        = (int) get_post_meta( $post->ID, 'dt_original_blog_id', true );
		} elseif ( get_post_meta( $post->ID, 'dt_full_connection', true ) ) {
			// This connection was pushed from an external connection.
			$this->connection_type      = 'external';
			$this->connection_direction = 'pushed';

			/*
			 * The connection ID stored in post meta is incorrect.
			 *
			 * The stored connection is the ID of this connection on the source server.
			 * Instead this lists the remote site's URL as the connection ID.
			 */
			$this->connection_id = get_post_meta( $post->ID, 'dt_original_site_url', true );
		} elseif ( get_post_meta( $post->ID, 'dt_original_source_id', true ) ) {
			// Post was pulled from an external connection.
			$this->connection_type      = 'external';
			$this->connection_direction = 'pulled';
			$this->connection_id        = (int) get_post_meta( $post->ID, 'dt_original_source_id', true );
		}
	}

	/**
	 * Magic method for calling methods on the post object.
	 *
	 * This is used to ensure the post object is switched to the correct site before
	 * running any of the internal methods.
	 *
	 * @since 2.0.0
	 *
	 * @param string $name      Method name.
	 * @param array  $arguments Method arguments.
	 */
	public function __call( $name, $arguments ) {
		$switched  = false;
		$cache_key = md5( "{$name}::" . wp_json_encode( $arguments ) );
		if ( ! method_exists( $this, $name ) ) {
			// Emulate default behavior of calling non existent method (a fatal error).
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
			trigger_error(
				sprintf(
					/* translators: %s: method name */
					esc_html__( 'Call to undefined method %s', 'distributor' ),
					esc_html( __CLASS__ . '::' . $name . '()' )
				),
				E_USER_ERROR
			);
		}

		if ( get_current_blog_id() !== $this->site_id ) {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- throwing a warning is the correct behavior.
				trigger_error( esc_html__( 'DistributorPost object was called from a switched site.', 'distributor' ), E_USER_WARNING );
			}
			// array_key_exists as opposed to isset to avoid false negatives.
			if ( array_key_exists( $cache_key, $this->switched_site_cache ) ) {
				/*
				 * Avoid switching sites if the result is already cached.
				 *
				 * Due to the use of filters within various functions called by
				 * the helper methods, this data may not be correct at runtime if
				 * hooks have been added or removed. However, caching is a performance
				 * compromise to avoid switching sites on every call.
				 */
				return $this->switched_site_cache[ $cache_key ];
			}
			switch_to_blog( $this->site_id );
			$switched = true;
		}
		$result                                  = call_user_func_array( array( $this, $name ), $arguments );
		$this->switched_site_cache[ $cache_key ] = $result;
		if ( $switched ) {
			restore_current_blog();
		}
		return $result;
	}

	/**
	 * Magic getter method.
	 *
	 * This method is used to get the value of the `source_site` property and
	 * populate it if needs be. For internal connections the post permalink is
	 * updated with live data.
	 *
	 * @since 2.0.0
	 *
	 * @param string $name Property name.
	 * @return mixed
	 */
	public function __get( $name ) {
		if ( in_array( $name, array( 'source_site', 'original_post_url' ), true ) ) {
			$this->populate_source_site();
		}

		return $this->$name;
	}

	/**
	 * Magic isset method.
	 *
	 * This method is used to check if the `source_site` property is set and
	 * populate it if needs be.
	 *
	 * @since 2.0.0
	 *
	 * @param string $name Property name.
	 * @return bool
	 */
	public function __isset( $name ) {
		if ( 'source_site' === $name && empty( $this->source_site ) ) {
			$this->populate_source_site();
			return ! empty( $this->source_site );
		}

		return isset( $this->$name );
	}

	/**
	 * Populate the source site data for internal connections.
	 *
	 * This populates data from the source site used by internal connections.
	 * The data is populated in one function call to avoid unnecessary calls to
	 * switch_to_blog() and restore_current_blog().
	 *
	 * @todo Consider populating if the `switch_blog` action fires before site data is accessed.
	 *
	 * @since 2.0.0
	 *
	 * @return void
	 */
	protected function populate_source_site() {
		if ( ! empty( $this->source_site ) ) {
			// Already populated.
			return;
		}

		if ( $this->is_source ) {
			// This is the source post.
			$this->source_site = [
				'home_url' => home_url(),
				'name'     => get_bloginfo( 'name' ),
			];
			return;
		} elseif ( 'internal' !== $this->connection_type || empty( $this->connection_id ) ) {
			// Populate from the meta data.
			$this->source_site = [
				'home_url' => get_post_meta( $this->post->ID, 'dt_original_site_url', true ),
				'name'     => get_post_meta( $this->post->ID, 'dt_original_site_name', true ),
			];
			return;
		}

		$switch_to_site = false;
		if ( get_current_blog_id() !== $this->connection_id ) {
			switch_to_blog( $this->connection_id );
			$switch_to_site = true;
		}

		// Get the site data.
		$this->source_site = [
			'home_url' => home_url(),
			'name'     => get_bloginfo( 'name' ),
		];

		// Update the original post permalink with live data.
		$this->original_post_url = get_permalink( $this->original_post_id );

		// Restore the current site.
		if ( $switch_to_site ) {
			restore_current_blog();
		}
	}

	/**
	 * Determines whether the post has blocks.
	 *
	 * This test optimizes for performance rather than strict accuracy, detecting
	 * the pattern of a block but not validating its structure. For strict accuracy,
	 * you should use the block parser on post content.
	 *
	 * Wraps the WordPress function of the same name.
	 *
	 * @since 2.0.0
	 *
	 * @return bool Whether the post has blocks.
	 */
	protected function has_blocks() {
		return has_blocks( $this->post->post_content );
	}

	/**
	 * Determines whether a $post or a string contains a specific block type.
	 *
	 * This test optimizes for performance rather than strict accuracy, detecting
	 * whether the block type exists but not validating its structure and not checking
	 * reusable blocks. For strict accuracy, you should use the block parser on post content.
	 *
	 * Wraps the WordPress function of the same name.
	 *
	 * @since 2.0.0
	 *
	 * @param string $block_name Full block type to look for.
	 * @return bool Whether the post content contains the specified block.
	 */
	protected function has_block( $block_name ) {
		return has_block( $block_name, $this->post->post_content );
	}

	/**
	 * Get the post ID.
	 *
	 * @since 2.0.0
	 *
	 * @return int Post ID.
	 */
	protected function get_the_id() {
		return $this->post->ID;
	}

	/**
	 * Get the post permalink.
	 *
	 * @since 2.0.0
	 *
	 * @return string Post permalink.
	 */
	protected function get_permalink() {
		return get_permalink( $this->post );
	}

	/**
	 * Get the post type.
	 *
	 * @since 2.0.0
	 *
	 * @return string Post type.
	 */
	protected function get_post_type() {
		return get_post_type( $this->post );
	}

	/**
	 * Get the post's thumbnail ID.
	 *
	 * @since 2.0.0
	 *
	 * @return int|false Post thumbnail ID or false if no thumbnail is set.
	 */
	protected function get_post_thumbnail_id() {
		return get_post_thumbnail_id( $this->post );
	}

	/**
	 * Get the post's thumbnail URL.
	 *
	 * @since 2.0.0
	 *
	 * @param string $size Thumbnail size. Defaults to 'post-thumbnail'.
	 * @return string|false The post's thumbnail URL or false if no thumbnail is set.
	 */
	protected function get_post_thumbnail_url( $size = 'post-thumbnail' ) {
		return get_the_post_thumbnail_url( $this->post, $size );
	}

	/**
	 * Get the post's thumbnail HTML.
	 *
	 * @since 2.0.0
	 *
	 * @param string $size Thumbnail size. Defaults to 'post-thumbnail'.
	 * @param array  $attr Optional. Attributes for the image markup. Default empty.
	 * @return string|false The post's thumbnail HTML or false if no thumbnail is set.
	 */
	protected function get_the_post_thumbnail( $size = 'post-thumbnail', $attr = '' ) {
		return get_the_post_thumbnail( $this->post, $size, $attr );
	}

	/**
	 * Get the post's canonical URL.
	 *
	 * For distributed posts, this is the permalink of the original post. For
	 * the original post, this is the result of get_permalink().
	 *
	 * @since 2.0.0
	 *
	 * @param  string|null $canonical_url The post's canonical URL. If specified, this will be returned
	 *                                    if the canonical URL does not need to be replaced by the
	 *                                    original source URL.
	 * @return string The post's canonical URL.
	 */
	protected function get_canonical_url( $canonical_url = null ) {
		if (
			$this->is_source
			|| $this->original_deleted
			|| ! $this->is_linked
			|| ! $this->connection_id
			|| ! $this->original_post_url
		) {
			if ( null === $canonical_url ) {
				return $this->get_permalink();
			}
			return $canonical_url;
		}

		return $this->original_post_url;
	}

	/**
	 * Get the post's author name.
	 *
	 * For distributed posts this is the name of the original site. For the
	 * original post, this is the result of get_the_author().
	 *
	 * @since 2.0.0
	 *
	 * @param  string|null $author_name The post's author name. If specified, this will be returned if the
	 *                                  author name does not need to be replaced by the original source name.
	 * @return string The post's author name.
	 */
	protected function get_author_name( $author_name = null ) {
		$settings = Utils\get_settings();

		if (
			empty( $settings['override_author_byline'] )
			|| $this->is_source
			|| $this->original_deleted
			|| ! $this->is_linked
			|| ! $this->connection_id
			|| ! $this->original_post_url
		) {
			if ( null === $author_name ) {
				return get_the_author_meta( 'display_name', $this->post->post_author );
			}
			return $author_name;
		}

		$this->populate_source_site();
		return $this->source_site['name'];
	}

	/**
	 * Get the post's author URL.
	 *
	 * For distributed posts this is a link to the original site. For the
	 * original post, this is the result of get_author_posts_url().
	 *
	 * @since 2.0.0
	 *
	 * @param  string|null $author_link The author's posts URL. If specified, this will be returned if the
	 *                                  author link does not need to be replaced by the original source name.
	 * @return string The post's author link.
	 */
	protected function get_author_link( $author_link = null ) {
		$settings = Utils\get_settings();

		if (
			empty( $settings['override_author_byline'] )
			|| $this->is_source
			|| $this->original_deleted
			|| ! $this->is_linked
			|| ! $this->connection_id
			|| ! $this->original_post_url
		) {
			if ( null === $author_link ) {
				return get_author_posts_url( $this->post->post_author );
			}
			return $author_link;
		}

		$this->populate_source_site();
		return $this->source_site['home_url'];
	}

	/**
	 * Get the post's distributable meta data.
	 *
	 * @since 2.0.0
	 *
	 * @return array Array of meta data.
	 */
	protected function get_meta() {
		return Utils\prepare_meta( $this->post->ID );
	}

	/**
	 * Get the post's distributable terms.
	 *
	 * @since 2.0.0
	 *
	 * @var array[] {
	 *    @type WP_Term[] Post terms keyed by taxonomy.
	 * }
	 */
	protected function get_terms() {
		return Utils\prepare_taxonomy_terms( $this->post->ID );
	}

	/**
	 * Format media items for consumption
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	protected function get_media() {
		$post_id = $this->post->ID;
		if ( $this->has_blocks() ) {
			$raw_media = $this->parse_media_blocks();
		} else {
			$raw_media = get_attached_media( get_allowed_mime_types(), $post_id );
			// Parse images from post content.
			$parsed_media = $this->parse_images_from_post_content();
			foreach ( $parsed_media as $media_post ) {
				$raw_media[ $media_post->ID ] = $media_post;
			}
		}

		$featured_image_id = $this->get_post_thumbnail_id();
		$found_featured    = false;
		$media_array       = array();

		foreach ( $raw_media as $media_post ) {
			$media_item = Utils\format_media_post( $media_post, $post_id );

			if ( $media_item['featured'] ) {
				$found_featured = true;
			}

			$media_array[] = $media_item;
		}

		if ( ! empty( $featured_image_id ) && ! $found_featured ) {
			$featured_image             = Utils\format_media_post( get_post( $featured_image_id ) );
			$featured_image['featured'] = true;

			$media_array[] = $featured_image;
		}

		return $media_array;
	}

	/**
	 * Get the extra data for the post to be processed on the target site.
	 *
	 * @since 2.2.0
	 *
	 * @return array[] {
	 *   Array of extra data keyed by the data name.
	 *
	 *   @type mixed $data_name The extra data returned by the callback function.
	 * }
	 */
	protected function get_extra_data() {
		// Post data to pass to the filter.
		$post_data = array(
			'title'                          => html_entity_decode( get_the_title( $this->post->ID ), ENT_QUOTES, get_bloginfo( 'charset' ) ),
			'slug'                           => $this->post->post_name,
			'type'                           => $this->post->post_type,
			'content'                        => Utils\get_processed_content( $this->post->post_content ),
			'excerpt'                        => $this->post->post_excerpt,
			'parent'                         => ! empty( $this->post->post_parent ) ? (int) $this->post->post_parent : 0,
			'status'                         => $this->post->post_status,
			'date'                           => $this->post->post_date,
			'date_gmt'                       => $this->post->post_date_gmt,

			// Original site and post data.
			'distributor_original_site_name' => $this->source_site['name'],
			'distributor_original_site_url'  => $this->source_site['home_url'],
			'distributor_original_post_url'  => $this->get_permalink(),
			'distributor_original_post_id'   => $this->post->ID,
		);

		/**
		 * Filters whether to process extra data for the post.
		 *
		 * @since 2.2.0
		 * @hook dt_process_extra_data
		 *
		 * @param {bool} $process_extra_data Whether to process extra data.
		 * @param {array} $post_data The post data.
		 * @return {bool} Whether to process extra data.
		 */
		if ( ! apply_filters( 'dt_process_extra_data', true, $post_data ) ) {
			return array();
		}

		$extra_data      = array();
		$registered_data = distributor_get_registered_data();

		foreach ( $registered_data as $data_key => $data ) {
			$location    = $data['location'];
			$attributes  = $data['attributes'];
			$callback_fn = $data['pre_distribute_cb'] ?? null;

			// Skip if the callback function is not provided or not callable.
			if ( empty( $callback_fn ) || ! is_callable( $callback_fn ) ) {
				continue;
			}

			if ( 'post_meta' === $location ) {
				$meta_key = $attributes['meta_key'] ?? '';
				if ( empty( $meta_key ) ) {
					continue;
				}

				// Handle multiple meta keys.
				if ( is_array( $meta_key ) ) {
					$meta_data = array();
					foreach ( $meta_key as $key ) {
						$meta_value = get_post_meta( $this->post->ID, $key, false );
						if ( is_array( $meta_value ) && 1 === count( $meta_value ) ) {
							$meta_data[ $key ] = $meta_value[0];
						} else {
							$meta_data[ $key ] = $meta_value;
						}
					}
					$extra_data[ $data_key ] = call_user_func_array( $callback_fn, array( $meta_data, $this->post->ID ) );
					continue;
				}

				$meta_value = get_post_meta( $this->post->ID, $meta_key, false );
				if ( is_array( $meta_value ) && 1 === count( $meta_value ) ) {
					$meta_data = $meta_value[0];
				} else {
					$meta_data = $meta_value;
				}
				$extra_data[ $data_key ] = call_user_func_array( $callback_fn, array( $meta_data, $this->post->ID ) );
			} elseif ( 'post_content' === $location ) {
				$shortcode  = $attributes['shortcode'] ?? '';
				$block_name = $attributes['block_name'] ?? '';
				if ( empty( $shortcode ) && empty( $block_name ) ) {
					continue;
				}

				// Process the block if block name is provided.
				if ( ! empty( $block_name ) && $this->has_blocks() ) {
					$block_attribute = $attributes['block_attribute'] ?? '';
					if ( ! empty( $block_attribute ) && $this->has_block( $block_name ) ) {
						$attribute_values = array();
						$blocks           = parse_blocks( $this->post->post_content );

						foreach ( $blocks as $block ) {
							$attribute_values = array_merge( $attribute_values, $this->parse_blocks_for_attribute_values( $block, $block_name, $block_attribute ) );
						}

						$block_data = array();
						if ( ! empty( $attribute_values ) ) {
							foreach ( $attribute_values as $value ) {
								$block_data[] = call_user_func_array( $callback_fn, array( $value, $this->post->ID ) );
							}
						}

						$extra_data[ $data_key ] = $block_data;
					}
				}

				// Process the shortcode if shortcode is provided.
				if ( ! empty( $shortcode ) ) {
					$shortcode_attribute = $attributes['shortcode_attribute'] ?? '';
					if ( ! empty( $shortcode_attribute ) && has_shortcode( $this->post->post_content, $shortcode ) ) {
						$attribute_values = $this->get_shortcode_attribute_values( $this->post->post_content, $shortcode, $shortcode_attribute );

						$shortcode_data = array();
						if ( ! empty( $attribute_values ) ) {
							foreach ( $attribute_values as $value ) {
								$shortcode_data[] = call_user_func_array( $callback_fn, array( $value, $this->post->ID ) );
							}
						}

						$extra_data[ $data_key ] = $shortcode_data;
					}
				}
			}
		}

		/**
		 * Filters the extra data for the post to be processed on the target site.
		 *
		 * @since 2.2.0
		 * @hook dt_extra_data
		 *
		 * @param {array}           $extra_data Extra data for the post.
		 * @param {DistributorPost} $this       The DistributorPost object.
		 * @return {array} Extra data for the post.
		 */
		$extra_data = apply_filters( 'dt_extra_data', $extra_data, $this );

		return $extra_data;
	}

	/**
	 * Parse blocks to obtain value of given attribute.
	 *
	 * @since 2.2.0
	 *
	 * @param array        $block           Block to parse.
	 * @param string       $block_name      Block name to look for.
	 * @param string|array $block_attribute Block attribute(s) to look for.
	 * @return array Array of attribute values.
	 */
	protected function parse_blocks_for_attribute_values( $block, $block_name, $block_attribute ) {
		$attribute_values = array();

		if ( ! empty( $block['innerBlocks'] ) ) {
			foreach ( $block['innerBlocks'] as $inner_block ) {
				$attribute_values = array_merge( $attribute_values, $this->parse_blocks_for_attribute_values( $inner_block, $block_name, $block_attribute ) );
			}
		}

		if ( $block['blockName'] === $block_name ) {
			// Handle multiple block attributes.
			if ( is_array( $block_attribute ) ) {
				$data = array();
				foreach ( $block_attribute as $attribute ) {
					if ( isset( $block['attrs'][ $attribute ] ) ) {
						$data[ $attribute ] = $block['attrs'][ $attribute ];
					}
				}
				$attribute_values[] = $data;
			} elseif ( isset( $block['attrs'][ $block_attribute ] ) ) {
				$attribute_values[] = $block['attrs'][ $block_attribute ];
			}
		}

		return $attribute_values;
	}

	/**
	 * Get the attribute values of a given shortcode.
	 *
	 * @since 2.2.0
	 *
	 * @param string       $content   The post content.
	 * @param string       $shortcode The shortcode to look for.
	 * @param string|array $attribute The attribute(s) to look for.
	 * @return array Array of attribute values.
	 */
	protected function get_shortcode_attribute_values( $content, $shortcode, $attribute ) {
		$attribute_values = array();

		// Build a regex that matches only our target shortcode.
		$pattern = get_shortcode_regex( array( $shortcode ) );

		// Use preg_match_all to find all matching shortcodes.
		preg_match_all( "/$pattern/", $content, $matches, PREG_SET_ORDER );

		// Loop through each matched shortcode.
		foreach ( $matches as $match ) {
			// $match[0] is full match, $match[2] is shortcode tag and $match[3] is attributes string.
			if ( $match[2] === $shortcode ) {
				$attrs = shortcode_parse_atts( $match[3] );
				if ( is_array( $attribute ) ) {
					$data = array();
					foreach ( $attribute as $attr ) {
						if ( isset( $attrs[ $attr ] ) ) {
							$data[ $attr ] = $attrs[ $attr ];
						}
					}
					$attribute_values[] = $data;
				} elseif ( isset( $attrs[ $attribute ] ) ) {
					$attribute_values[] = $attrs[ $attribute ];
				}
			}
		}

		return $attribute_values;
	}

	/**
	 * Parse the post's content to obtain media items by image tags.
	 *
	 * @return array<WP_Post> Array of media posts.
	 */
	protected function parse_images_from_post_content() {
		$processor = new \WP_HTML_Tag_Processor( $this->post->post_content );

		$media = array();
		while ( $processor->next_tag( 'img' ) ) {
			$classes = explode( ' ', $processor->get_attribute( 'class' ) ?? ' ' );

			if ( ! is_array( $classes ) ) {
				continue;
			}

			// Filter out classes that are not image classes.
			$classes = array_filter(
				$classes,
				function( $class ) {
					return strpos( $class, 'wp-image-' ) === 0;
				}
			);

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

			$image_id   = (int) str_replace( 'wp-image-', '', current( $classes ) );
			$media_post = get_post( $image_id );

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

			$media[ $media_post->ID ] = $media_post;
		}

		$media = array_filter( $media );

		return $media;
	}

	/**
	 * Parse the post's content to obtain media items.
	 *
	 * This uses the block parser to find media items within the post content
	 * regardless of whether the media is attached to the post or not.
	 *
	 * @since 2.0.0
	 *
	 * @return WP_Post[] Array of media posts.
	 */
	protected function parse_media_blocks() {
		$found = false;

		// Note: changes to the cache key or group should be reflected in `includes/settings.php`
		$cache_key   = "dt_media::{$this->post->ID}";
		$cache_group = 'dt::post';
		$media       = wp_cache_get( $cache_key, $cache_group, false, $found );

		if ( ! $found ) {
			// Parse blocks to determine attached media.
			$media = array();

			$blocks = parse_blocks( $this->post->post_content );

			foreach ( $blocks as $block ) {
				$media = array_merge( $media, $this->parse_blocks_for_attachment_id( $block ) );
			}

			// Only the IDs are cached to keep the cache size down.
			wp_cache_set( $cache_key, $media, $cache_group );
		}

		/*
		 * Prime the cache for the individual media items in full.
		 *
		 * The post, term and meta caches are primed individually to work
		 * around a WordPress bug in which `_prime_post_caches()` will not
		 * warm the secondary caches if the post object is already cached.
		 *
		 * See https://core.trac.wordpress.org/ticket/57163
		 */
		_prime_post_caches( $media, false, false );
		update_object_term_cache( $media, 'attachment' );
		update_postmeta_cache( $media );
		$media = array_map( 'get_post', $media );
		$media = array_filter( $media );

		return $media;
	}

	/**
	 * Parse blocks to obtain each media item's attachment ID.
	 *
	 * @since 2.0.0
	 *
	 * @param array $block Block to parse.
	 * @return int[] Array of media attachment IDs.
	 */
	protected function parse_blocks_for_attachment_id( $block ) {
		$media_blocks = array(
			'core/image' => 'id',
			'core/audio' => 'id',
			'core/video' => 'id',
		);
		$media        = array();

		/**
		 * Filters blocks to consider as media.
		 *
		 * The array keys are the block names and the values indicate the
		 * attribute containing the media's attachment ID.
		 *
		 * The Distributor defaults are {
		 *    'core/image' => 'id',
		 *    'core/audio' => 'id',
		 *    'core/video' => 'id',
		 * }
		 *
		 * @since 2.0.0
		 * @hook dt_parse_media_blocks
		 *
		 * @param {array} $media_blocks Array of media blocks.
		 *
		 * @return {array} Modified array of media blocks.
		 */
		$media_blocks = apply_filters( 'dt_parse_media_blocks', $media_blocks );

		$block_names = array_keys( $media_blocks );

		if ( ! empty( $block['innerBlocks'] ) ) {
			foreach ( $block['innerBlocks'] as $inner_block ) {
				$media = array_merge( $media, $this->parse_blocks_for_attachment_id( $inner_block ) );
			}
		}

		if ( in_array( $block['blockName'], $block_names, true ) ) {
			$attribute_key = $media_blocks[ $block['blockName'] ];

			if ( isset( $block['attrs'][ $attribute_key ] ) ) {
				$media[] = $block['attrs'][ $attribute_key ];
			}
		}

		return $media;
	}

	/**
	 * Get the post data for distribution.
	 *
	 * @since 2.0.0
	 *
	 * @return array {
	 *    Post data.
	 *
	 *    @type string $title             Post title.
	 *    @type string $slug              Post slug.
	 *    @type string $post_type         Post type.
	 *    @type string $content           Processed post content.
	 *    @type string $excerpt           Post excerpt.
	 *    @type int    $parent            Post parent ID.
	 *    @type string $status            Post status.
	 *    @type array  $distributor_media Media data.
	 *    @type array  $distributor_terms Post terms.
	 *    @type array  $distributor_meta  Post meta.
	 * }
	 */
	protected function post_data() {
		$this->populate_source_site();
		return array(
			'title'                          => html_entity_decode( get_the_title( $this->post->ID ), ENT_QUOTES, get_bloginfo( 'charset' ) ),
			'slug'                           => $this->post->post_name,
			'type'                           => $this->post->post_type,
			'content'                        => Utils\get_processed_content( $this->post->post_content ),
			'excerpt'                        => $this->post->post_excerpt,
			'parent'                         => ! empty( $this->post->post_parent ) ? (int) $this->post->post_parent : 0,
			'status'                         => $this->post->post_status,
			'date'                           => $this->post->post_date,
			'date_gmt'                       => $this->post->post_date_gmt,

			'distributor_media'              => $this->get_media(),
			'distributor_terms'              => $this->get_terms(),
			'distributor_meta'               => $this->get_meta(),

			// Original site and post data.
			'distributor_original_site_name' => $this->source_site['name'],
			'distributor_original_site_url'  => $this->source_site['home_url'],
			'distributor_original_post_url'  => $this->get_permalink(),
			'distributor_original_post_id'   => $this->post->ID,

			// Extra data for handling stored IDs.
			'distributor_extra_data'         => $this->get_extra_data(),
		);
	}

	/**
	 * Get the post data in a format suitable for wp_insert_post().
	 *
	 * @since 2.0.0
	 *
	 * @todo `distributor_media` needs work for unattached media items.
	 * @todo check if `distributor_raw_content` should be included here too.
	 *
	 * @param  array $args {
	 *     Optional. Array of push arguments
	 *
	 *     @type int    $remote_post_id Post ID on remote site. If not provided,
	 *                                  a new post will be created.
	 *     @type string $post_status    The post status to use on the remote site.
	 *                                  Ignored when updating posts.
	 * }
	 *
	 * @return array {
	 *    Post data.
	 *
	 *    @type string $post_title   Post title.
	 *    @type string $post_name    Post slug.
	 *    @type string $post_type    Post type.
	 *    @type string $post_content Post content. The raw content is used for posts containing blocks.
	 *    @type string $post_excerpt Post excerpt.
	 *    @type array  $tax_input    Post terms.
	 *    @type array  $meta_input   Post meta.
	 *
	 *    @type array  $distributor_media Media data.
	 * }
	 */
	protected function to_insert( $args = array() ) {
		$insert       = [];
		$post_data    = $this->post_data();
		$key_mappings = [
			'post_title'   => 'title',
			'post_name'    => 'slug',
			'post_type'    => 'type',
			'post_content' => 'content',
			'post_excerpt' => 'excerpt',
			'post_status'  => 'status',
			'terms'        => 'distributor_terms',
			'meta'         => 'distributor_meta',

			// This needs to be figured out.
			'media'        => 'distributor_media',
		];

		foreach ( $key_mappings as $key => $value ) {
			$insert[ $key ] = $post_data[ $value ];
		}

		// Additional data required for wp_insert_post().
		$insert['post_author'] = ! empty( $this->post->post_author ) ? $this->post->post_author : get_current_user_id();

		// If the post has blocks, use the raw content.
		if ( $this->has_blocks() ) {
			$insert['post_content'] = $this->post->post_content;
		}

		if ( ! empty( $args['remote_post_id'] ) ) {
			// Updating an existing post.
			$insert['ID'] = (int) $args['remote_post_id'];
			// Never update the post status when updating a post.
			unset( $insert['post_status'] );
		} elseif ( ! empty( $args['post_status'] ) ) {
			$insert['post_status'] = $args['post_status'];
		}

		if (
			isset( $insert['post_status'] )
			&& 'future' === $insert['post_status']
		) {
			// Set the post date to the future date.
			$insert['post_date']     = $post_data['date'];
			$insert['post_date_gmt'] = $post_data['date_gmt'];
		}

		// Post meta used by wp_insert_post, wp_update_post.
		$insert['meta_input'] = array(
			'dt_original_post_id'  => $post_data['distributor_original_post_id'],
			'dt_original_post_url' => $post_data['distributor_original_post_url'],
		);
		if ( ! empty( $post_data['parent'] ) ) {
			$insert['meta_input']['dt_original_post_parent'] = $post_data['parent'];
		}

		if ( ! empty( $post_data['distributor_extra_data'] ) ) {
			$insert['distributor_extra_data'] = $post_data['distributor_extra_data'];
		}

		return $insert;
	}

	/**
	 * Get the post data in a format suitable for the pull screen.
	 *
	 * This is a wrapper for the ::to_insert() method that includes extra
	 * data required for the pull screen.
	 *
	 * @since 2.0.0
	 *
	 * @param mixed $args {
	 *     Optional. Array of push arguments
	 *     @see ::to_insert() for arguments.
	 * }
	 * @return array Post data formatted for the pull screen.
	 */
	protected function to_pull_list( $args = array() ) {
		$display_data = $this->to_insert( $args );

		// Additional information required for pull screen.
		$display_data['ID']                             = $this->post->ID;
		$display_data['post_date']                      = $this->post->post_date;
		$display_data['post_date_gmt']                  = $this->post->post_date_gmt;
		$display_data['post_modified']                  = $this->post->post_modified;
		$display_data['post_modified_gmt']              = $this->post->post_modified_gmt;
		$display_data['post_password']                  = $this->post->post_password;
		$display_data['guid']                           = $this->post->guid;
		$display_data['comment_status']                 = $this->post->comment_status;
		$display_data['ping_status']                    = $this->post->ping_status;
		$display_data['link']                           = $this->get_permalink();
		$display_data['distributor_original_site_name'] = $this->source_site['name'];
		$display_data['distributor_original_site_url']  = $this->source_site['home_url'];

		/**
		 * Filters the post data for when they are being formatted for a pull
		 *
		 * @since 2.0.3
		 * @hook dt_post_to_pull
		 *
		 * @param {array} $display_data The post data.
		 *
		 * @return {array} Modified post data.
		 */
		return apply_filters( 'dt_post_to_pull', $display_data );
	}

	/**
	 * Get the post data in a format suitable for the distributor REST API endpoint.
	 *
	 * @since 2.0.0
	 *
	 * @param array $rest_args Optional. Arguments to be passed to the REST API endpoint.
	 * @return array Post data formatted for the REST API endpoint.
	 */
	protected function to_rest( $rest_args = array() ) {
		$post_data = $this->post_data();

		/*
		 * Unset dates.
		 *
		 * External connections do not allow for the pulling or pushing of
		 * scheduled posts so these can be ignored.
		 */
		unset( $post_data['date'] );
		unset( $post_data['date_gmt'] );

		if ( ! empty( $post_data['parent'] ) ) {
			$post_data['distributor_original_post_parent'] = (int) $post_data['parent'];
		}
		unset( $post_data['parent'] );

		// Replace any default values with those that have been passed.
		$post_data = array_merge( $post_data, $rest_args );

		/*
		 * Rename the original post ID to the remote post ID.
		 *
		 * JSON requests are sent to external sites via the REST API so from the perspective
		 * of the external site, the original post ID is the remote post ID.
		 */
		$post_data['distributor_remote_post_id'] = $post_data['distributor_original_post_id'];
		unset( $post_data['distributor_original_post_id'] );

		/*
		 * Check if the post has block to determine whether to use the raw content or not.
		 *
		 * This is used instead of `use_block_editor_for_post()` as the latter may be
		 * filtered by the classic editor plugin to return false for posts do in fact
		 * contain blocks.
		 */
		if ( $this->has_blocks() ) {
			$post_data['distributor_raw_content'] = $this->post->post_content;
		}

		return $post_data;
	}
}
