<?php
/**
 * Registers the API field for Publicize connections.
 *
 * @package automattic/jetpack-publicize
 */

namespace Automattic\Jetpack\Publicize\REST_API;

use Automattic\Jetpack\Publicize\Publicize_Base;
use WP_Error;
use WP_Post;
use WP_REST_Request;

/**
 * The class to register the field and augment requests
 * to Publicize supported post types.
 *
 * @phan-constructor-used-for-side-effects
 */
class Connections_Post_Field {

	const FIELD_NAME = 'jetpack_publicize_connections';

	/**
	 * Array of post IDs that have been updated.
	 *
	 * @var array
	 */
	private $meta_saved = array();

	/**
	 * Used to memoize the updates for a given post.
	 *
	 * @var array
	 */
	public $memoized_updates = array();

	/**
	 * Requested connections for a new post being created, kept until the post
	 * ID is known so the skip meta can be persisted before Publicize runs.
	 *
	 * @var array
	 */
	private $new_post_connections = array();

	/**
	 * The request that is creating a new post, kept alongside
	 * $new_post_connections so per-connection overrides can be saved.
	 *
	 * @var WP_REST_Request|null
	 */
	private $new_post_request = null;

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'rest_api_init', array( $this, 'register_fields' ) );
	}

	/**
	 * Registers the jetpack_publicize_connections field. Called
	 * automatically on `rest_api_init()`.
	 */
	public function register_fields() {
		$post_types = get_post_types_by_support( 'publicize' );
		foreach ( $post_types as $post_type ) {
			// Adds meta support for those post types that don't already have it.
			// Only runs during REST API requests, so it doesn't impact UI.
			if ( ! post_type_supports( $post_type, 'custom-fields' ) ) {
				add_post_type_support( $post_type, 'custom-fields' );
			}

			// We use these hooks and not the update_callback because we must updateth meta
			// before we set the post as published, otherwise the wrong connections could be used.
			add_filter( 'rest_pre_insert_' . $post_type, array( $this, 'rest_pre_insert' ), 10, 2 );
			add_action( 'rest_insert_' . $post_type, array( $this, 'rest_insert' ), 10, 3 );

			register_rest_field(
				$post_type,
				self::FIELD_NAME,
				array(
					'get_callback' => array( $this, 'get' ),
					'schema'       => $this->get_schema(),
				)
			);
		}
	}

	/**
	 * Defines data structure and what elements are visible in which contexts
	 */
	public function get_schema() {
		return array(
			'$schema' => 'http://json-schema.org/draft-04/schema#',
			'title'   => 'jetpack-publicize-post-connections',
			'type'    => 'array',
			'context' => array( 'view', 'edit' ),
			'items'   => $this->post_connection_schema(),
			'default' => array(),
		);
	}

	/**
	 * Schema for the endpoint.
	 */
	private function post_connection_schema() {
		$connection_fields = Connections_Controller::get_the_item_schema();
		$deprecated_fields = array(
			'id'             => array(
				'type'        => 'string',
				'description' => __( 'Unique identifier for the Jetpack Social connection.', 'jetpack-publicize-pkg' ) . ' ' . sprintf(
					/* translators: %s is the new field name */
					__( 'Deprecated in favor of %s.', 'jetpack-publicize-pkg' ),
					'connection_id'
				),
			),
			'username'       => array(
				'type'        => 'string',
				'description' => __( 'Username of the connected account.', 'jetpack-publicize-pkg' ) . ' ' . sprintf(
					/* translators: %s is the new field name */
					__( 'Deprecated in favor of %s.', 'jetpack-publicize-pkg' ),
					'external_handle'
				),
			),
			'can_disconnect' => array(
				'description' => __( 'Whether the current user can disconnect this connection.', 'jetpack-publicize-pkg' ) . ' ' . __( 'Deprecated.', 'jetpack-publicize-pkg' ),
				'type'        => 'boolean',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		);

		return array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'jetpack-publicize-post-connection',
			'type'       => 'object',
			'properties' => array_merge(
				$deprecated_fields,
				$connection_fields,
				array(
					'enabled'        => array(
						'description' => __( 'Whether to share to this connection.', 'jetpack-publicize-pkg' ),
						'type'        => 'boolean',
						'context'     => array( 'edit' ),
					),
					'message'        => array(
						'description' => __( 'Custom message to use for this connection instead of the global message.', 'jetpack-publicize-pkg' ),
						'type'        => 'string',
						'context'     => array( 'edit' ),
					),
					'attached_media' => array(
						'description' => __( 'Custom media to attach for this connection instead of the global media.', 'jetpack-publicize-pkg' ),
						'type'        => 'array',
						'context'     => array( 'edit' ),
						'items'       => array(
							'type'       => 'object',
							'properties' => array(
								'id'   => array( 'type' => 'number' ),
								'url'  => array( 'type' => 'string' ),
								'type' => array( 'type' => 'string' ),
							),
						),
					),
					'media_source'   => array(
						'type' => 'string',
						'enum' => array(
							'featured-image',
							'sig',
							'media-library',
							'upload-video',
							'none',
						),
					),
				)
			),
		);
	}

	/**
	 * Permission check, based on module availability and user capabilities.
	 *
	 * @param int $post_id Post ID.
	 *
	 * @return true|WP_Error
	 */
	public function permission_check( $post_id ) {
		global $publicize;

		if ( ! $publicize ) {
			return new WP_Error(
				'publicize_not_available',
				__( 'Sorry, Jetpack Social is not available on your site right now.', 'jetpack-publicize-pkg' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		if ( $publicize->current_user_can_access_publicize_data( $post_id ) ) {
			return true;
		}

		return new WP_Error(
			'invalid_user_permission_publicize',
			__( 'Sorry, you are not allowed to access Jetpack Social data for this post.', 'jetpack-publicize-pkg' ),
			array( 'status' => rest_authorization_required_code() )
		);
	}

	/**
	 * The field's wrapped getter. Does permission checks and output preparation.
	 *
	 * This cannot be extended: implement `->get()` instead.
	 *
	 * @param mixed           $post_array Probably an array. Whatever the endpoint returns.
	 * @param string          $field_name  Should always match `->field_name`.
	 * @param WP_REST_Request $request     WP API request.
	 * @param string          $object_type Should always match `->object_type`.
	 *
	 * @return mixed
	 */
	public function get( $post_array, $field_name, $request, $object_type ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
		global $publicize;

		$post_id          = $post_array['id'] ?? 0;
		$full_schema      = $this->get_schema();
		$permission_check = $this->permission_check( $post_id );
		if ( is_wp_error( $permission_check ) ) {
			return $full_schema['default'];
		}

		$schema      = $full_schema['items'];
		$properties  = array_keys( $schema['properties'] );
		$connections = $publicize->get_filtered_connection_data( $post_id );

		if ( $publicize && $publicize->has_paid_features() ) {
			// Check if per-network customization is enabled.
			$customize_per_network = get_post_meta( $post_id, Publicize_Base::POST_CUSTOMIZE_PER_NETWORK, true );
			// Get per-connection overrides from post meta.
			$connection_overrides = get_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES, true );

			if ( ! is_array( $connection_overrides ) ) {
				$connection_overrides = array();
			}
		} else {
			$customize_per_network = false;
			$connection_overrides  = array();
		}

		$message_templates_enabled = $publicize && $publicize->has_paid_features();

		$output_connections = array();
		foreach ( $connections as $connection ) {
			$output_connection = array();
			foreach ( $properties as $property ) {
				if ( isset( $connection[ $property ] ) ) {
					$output_connection[ $property ] = $connection[ $property ];
				}
			}

			// Default `message` to the connection's own template when set
			if ( $message_templates_enabled && ! empty( $output_connection['template'] ) ) {
				$output_connection['message'] = $output_connection['template'];
			}

			// Merge per-connection overrides if global flag is enabled.
			$connection_id = $connection['connection_id'] ?? '';
			if ( $customize_per_network && ! empty( $connection_id ) && isset( $connection_overrides[ $connection_id ] ) ) {
				$override = $connection_overrides[ $connection_id ];
				if ( isset( $override['message'] ) ) {
					$output_connection['message'] = $override['message'];
				}
				if ( isset( $override['attached_media'] ) ) {
					$output_connection['attached_media'] = $override['attached_media'];
				}
				if ( isset( $override['media_source'] ) ) {
					$output_connection['media_source'] = $override['media_source'];
				}
			}

			$output_connections[] = $output_connection;
		}

		// TODO: Work out if this is necessary. We shouldn't be creating an invalid value here.
		$is_valid = rest_validate_value_from_schema( $output_connections, $full_schema, self::FIELD_NAME );
		if ( is_wp_error( $is_valid ) ) {
			return $is_valid;
		}

		$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
		return $this->filter_response_by_context( $output_connections, $full_schema, $context );
	}

	/**
	 * Prior to updating the post, first calculate which Services to
	 * Publicize to and which to skip.
	 *
	 * @param object          $post    Post data to insert/update.
	 * @param WP_REST_Request $request API request.
	 *
	 * @return object|WP_Error Filtered $post
	 */
	public function rest_pre_insert( $post, $request ) {
		$request_connections = ! empty( $request['jetpack_publicize_connections'] ) ? $request['jetpack_publicize_connections'] : array();

		$permission_check = $this->permission_check( empty( $post->ID ) ? 0 : $post->ID );
		if ( is_wp_error( $permission_check ) ) {
			return empty( $request_connections ) ? $post : $permission_check;
		}
		// memoize.
		$this->get_meta_to_update( $request_connections, $post->ID ?? 0 );

		if ( isset( $post->ID ) ) {
			// Set the meta before we mark the post as published so that publicize works as expected.
			// If this is not the case post end up on social media when they are marked as skipped.
			$this->update( $request_connections, $post, $request );
		} else {
			/*
			 * A brand new post has no ID yet, so we can't persist the skip meta here.
			 * Persist it as soon as the post is inserted, before Publicize processes
			 * the publish transition (priority 10). Otherwise a new published post is
			 * shared to every connection regardless of the requested `enabled` state.
			 */
			$this->new_post_connections = $request_connections;
			$this->new_post_request     = $request;
			add_action( 'transition_post_status', array( $this, 'set_meta_for_new_post' ), 1, 3 );
		}

		return $post;
	}

	/**
	 * Persist the Publicize skip meta for a newly created post.
	 *
	 * Runs on `transition_post_status` before Publicize flags the post for
	 * sharing, using the memoized data calculated in rest_pre_insert(). This is
	 * the create-time counterpart to the in-place update() done for existing posts.
	 *
	 * @param string  $new_status New post status.
	 * @param string  $old_status Old post status.
	 * @param WP_Post $post       Post object.
	 */
	public function set_meta_for_new_post( $new_status, $old_status, $post ) {
		if ( ! isset( $this->memoized_updates[0] ) || wp_is_post_revision( $post->ID ) ) {
			return;
		}

		// One-shot: the first non-revision transition is the post we created.
		remove_action( 'transition_post_status', array( $this, 'set_meta_for_new_post' ), 1 );

		// Move the memoized data to the real post ID now that we know it.
		$this->memoized_updates[ $post->ID ] = $this->memoized_updates[0];
		unset( $this->memoized_updates[0] );

		$this->update( $this->new_post_connections, $post, $this->new_post_request );

		$this->new_post_connections = array();
		$this->new_post_request     = null;
	}

	/**
	 * After creating a new post, update our cached data to reflect
	 * the new post ID.
	 *
	 * @param WP_Post         $post    Post data to update.
	 * @param WP_REST_Request $request API request.
	 * @param bool            $is_new  Is this a new post.
	 */
	public function rest_insert( $post, $request, $is_new ) {
		if ( ! $is_new ) {
			// An existing post was edited - no need to update
			// our cache - we started out knowing the correct
			// post ID.
			return;
		}

		if ( ! isset( $this->memoized_updates[0] ) ) {
			return;
		}

		$this->memoized_updates[ $post->ID ] = $this->memoized_updates[0];
		unset( $this->memoized_updates[0] );
	}

	/**
	 * Get list of meta data to update per post ID.
	 *
	 * @param array $requested_connections Publicize connections to update.
	 *              Items are either `{ id: (string) }` or `{ service_name: (string) }`.
	 * @param int   $post_id    Post ID.
	 */
	protected function get_meta_to_update( $requested_connections, $post_id = 0 ) {
		global $publicize;

		if ( ! $publicize || ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) ) {
			return array();
		}

		// Return memoized data first: a new post is already 'publish' by the time
		// we persist its skip meta, so the publish check below must not discard it.
		if ( isset( $this->memoized_updates[ $post_id ] ) ) {
			return $this->memoized_updates[ $post_id ];
		}

		$post = get_post( $post_id );
		if ( isset( $post->post_status ) && 'publish' === $post->post_status ) {
			return array();
		}

		$available_connections = $publicize->get_filtered_connection_data( $post_id );

		$changed_connections = array();

		// Build lookup mappings.
		$available_connections_by_connection_id = array();
		$available_connections_by_service_name  = array();
		foreach ( $available_connections as $available_connection ) {
			$available_connections_by_connection_id[ $available_connection['connection_id'] ] = $available_connection;

			if ( ! isset( $available_connections_by_service_name[ $available_connection['service_name'] ] ) ) {
				$available_connections_by_service_name[ $available_connection['service_name'] ] = array();
			}
			$available_connections_by_service_name[ $available_connection['service_name'] ][] = $available_connection;
		}

		// Handle { service_name: $service_name, enabled: (bool) }.
		// If the service is not available, it will be skipped.
		foreach ( $requested_connections as $requested_connection ) {
			if ( ! isset( $requested_connection['service_name'] ) ) {
				continue;
			}

			if ( ! isset( $available_connections_by_service_name[ $requested_connection['service_name'] ] ) ) {
				continue;
			}

			foreach ( $available_connections_by_service_name[ $requested_connection['service_name'] ] as $available_connection ) {
				if ( $requested_connection['connection_id'] === $available_connection['connection_id'] ) {
					$changed_connections[ $available_connection['connection_id'] ] = $requested_connection['enabled'];
					break;
				}
			}
		}

		// Handle { id: $id, enabled: (bool) }
		// These override the service_name settings.
		foreach ( $requested_connections as $requested_connection ) {
			if ( ! isset( $requested_connection['connection_id'] ) ) {
				continue;
			}

			if ( ! isset( $available_connections_by_connection_id[ $requested_connection['connection_id'] ] ) ) {
				continue;
			}

			$changed_connections[ $requested_connection['connection_id'] ] = $requested_connection['enabled'];
		}

		// Set all changed connections to their new value.
		foreach ( $changed_connections as $id => $enabled ) {
			$connection = $available_connections_by_connection_id[ $id ];

			if ( $connection['done'] ) {
				continue;
			}

			$available_connections_by_connection_id[ $id ]['enabled'] = $enabled;
		}

		$meta_to_update = array();
		// For all connections, ensure correct post_meta.
		foreach ( $available_connections_by_connection_id as $connection_id => $available_connection ) {
			if ( $available_connection['enabled'] ) {
				$meta_to_update[ $publicize->POST_SKIP_PUBLICIZE . $connection_id ] = null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			} else {
				$meta_to_update[ $publicize->POST_SKIP_PUBLICIZE . $connection_id ] = 1; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			}
		}

		$this->memoized_updates[ $post_id ] = $meta_to_update;

		return $meta_to_update;
	}

	/**
	 * Update the connections slated to be shared to.
	 *
	 * @param array           $requested_connections Publicize connections to update.
	 *              Items are either `{ id: (string) }` or `{ service_name: (string) }`.
	 * @param WP_Post         $post    Post data.
	 * @param WP_REST_Request $request API request.
	 */
	public function update( $requested_connections, $post, $request = null ) {
		global $publicize;

		if ( isset( $this->meta_saved[ $post->ID ] ) ) { // Make sure we only save it once - per request.
			return;
		}
		foreach ( $this->get_meta_to_update( $requested_connections, $post->ID ) as $meta_key => $meta_value ) {
			if ( null === $meta_value ) {
				delete_post_meta( $post->ID, $meta_key );
			} else {
				update_post_meta( $post->ID, $meta_key, $meta_value );
			}
		}

		// Save per-connection overrides.
		if ( $publicize && $publicize->has_paid_features() ) {
			$this->save_connection_overrides( $requested_connections, $post->ID, $request );
		}

		$this->meta_saved[ $post->ID ] = true;
	}

	/**
	 * Save per-connection customization overrides.
	 *
	 * Extracts message and attached_media from each connection and persists
	 * them to post meta when per-network customization is enabled.
	 *
	 * @param array           $requested_connections Array of connection data from the request.
	 * @param int             $post_id               Post ID.
	 * @param WP_REST_Request $request               API request.
	 */
	private function save_connection_overrides( $requested_connections, $post_id, $request = null ) {
		// Check if per-network customization is enabled - prefer request value over database.
		$customize_per_network = null;
		if ( $request && isset( $request['meta'][ Publicize_Base::POST_CUSTOMIZE_PER_NETWORK ] ) ) {
			$customize_per_network = $request['meta'][ Publicize_Base::POST_CUSTOMIZE_PER_NETWORK ];
		}
		if ( null === $customize_per_network ) {
			$customize_per_network = get_post_meta( $post_id, Publicize_Base::POST_CUSTOMIZE_PER_NETWORK, true );
		}

		// If customization is disabled, remove any existing overrides.
		if ( ! $customize_per_network ) {
			delete_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES );
			return;
		}

		// If the request does not have connections, skip.
		if ( ! isset( $request[ self::FIELD_NAME ] ) ) {
			return;
		}

		$overrides = array();

		foreach ( $requested_connections as $connection ) {
			// Only process if connection has a connection_id.
			if ( empty( $connection['connection_id'] ) ) {
				continue;
			}

			// Only save if connection has custom message or attached_media.
			if ( ! isset( $connection['message'] ) && ! isset( $connection['attached_media'] ) && ! isset( $connection['media_source'] ) ) {
				continue;
			}

			$connection_id               = $connection['connection_id'];
			$overrides[ $connection_id ] = array();

			// Save message (can be empty to use empty message).
			if ( isset( $connection['message'] ) ) {
				$overrides[ $connection_id ]['message'] = sanitize_textarea_field( $connection['message'] );
			}

			// Save attached_media (can be empty array to clear media).
			if ( isset( $connection['attached_media'] ) ) {
				$overrides[ $connection_id ]['attached_media'] = $this->sanitize_attached_media( $connection['attached_media'] );
			}

			// Save media_source (can be empty to use default).
			if ( isset( $connection['media_source'] ) ) {
				$overrides[ $connection_id ]['media_source'] = sanitize_text_field( $connection['media_source'] );
			}
		}

		// Only save if there are overrides, otherwise delete the meta.
		if ( ! empty( $overrides ) ) {
			update_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES, $overrides );
		} else {
			delete_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES );
		}
	}

	/**
	 * Sanitize attached media array.
	 *
	 * @param array $attached_media Array of media items.
	 * @return array Sanitized array of media items.
	 */
	private function sanitize_attached_media( $attached_media ) {
		if ( empty( $attached_media ) ) {
			return array();
		}

		$sanitized = array();

		foreach ( $attached_media as $media_item ) {
			if ( ! is_array( $media_item ) ) {
				continue;
			}

			$sanitized_item = array();

			if ( isset( $media_item['id'] ) ) {
				$sanitized_item['id'] = absint( $media_item['id'] );
			}

			if ( isset( $media_item['url'] ) ) {
				$sanitized_item['url'] = esc_url_raw( $media_item['url'] );
			}

			if ( isset( $media_item['type'] ) ) {
				$sanitized_item['type'] = sanitize_text_field( $media_item['type'] );
			}

			if ( ! empty( $sanitized_item ) ) {
				$sanitized[] = $sanitized_item;
			}
		}

		return $sanitized;
	}

	/**
	 * Removes properties that should not appear in the current
	 * request's context
	 *
	 * $context is a Core REST API Framework request attribute that is
	 * always one of:
	 * * view (what you see on the blog)
	 * * edit (what you see in an editor)
	 * * embed (what you see in, e.g., an oembed)
	 *
	 * Fields (and sub-fields, and sub-sub-...) can be flagged for a
	 * set of specific contexts via the field's schema.
	 *
	 * The Core API will filter out top-level fields with the wrong
	 * context, but will not recurse deeply enough into arrays/objects
	 * to remove all levels of sub-fields with the wrong context.
	 *
	 * This function handles that recursion.
	 *
	 * @param mixed  $value   Value passed to API request.
	 * @param array  $schema  Schema to validate against.
	 * @param string $context REST API Request context.
	 *
	 * @return mixed Filtered $value
	 */
	public function filter_response_by_context( $value, $schema, $context ) {
		if ( ! $this->is_valid_for_context( $schema, $context ) ) {
			// We use this intentionally odd looking WP_Error object
			// internally only in this recursive function (see below
			// in the `object` case). It will never be output by the REST API.
			// If we return this for the top level object, Core
			// correctly remove the top level object from the response
			// for us.
			return new WP_Error( '__wrong-context__' );
		}

		$schema_type = $schema['type'] ?? null;

		switch ( $schema_type ) {
			case 'array':
				if ( ! isset( $schema['items'] ) ) {
					return $value;
				}

				// Shortcircuit if we know none of the items are valid for this context.
				// This would only happen in a strangely written schema.
				if ( ! $this->is_valid_for_context( $schema['items'], $context ) ) {
					return array();
				}

				// Recurse to prune sub-properties of each item.
				foreach ( $value as $key => $item ) {
					$value[ $key ] = $this->filter_response_by_context( $item, $schema['items'], $context );
				}

				return $value;
			case 'object':
				if ( ! isset( $schema['properties'] ) ) {
					return $value;
				}

				foreach ( $value as $field_name => $field_value ) {
					if ( isset( $schema['properties'][ $field_name ] ) ) {
						$field_value = $this->filter_response_by_context( $field_value, $schema['properties'][ $field_name ], $context );
						if ( is_wp_error( $field_value ) && '__wrong-context__' === $field_value->get_error_code() ) {
							unset( $value[ $field_name ] );
						} else {
							// Respect recursion that pruned sub-properties of each property.
							$value[ $field_name ] = $field_value;
						}
					}
				}

				return (object) $value;
		}

		return $value;
	}

	/**
	 * Ensure that our request matches its expected context.
	 *
	 * @param array  $schema  Schema to validate against.
	 * @param string $context REST API Request context.
	 * @return bool
	 */
	private function is_valid_for_context( $schema, $context ) {
		return empty( $schema['context'] ) || in_array( $context, $schema['context'], true );
	}
}
