<?php
/**
 * Buffer API class
 *
 * @package WP_To_Social_Pro
 * @author  WP Zinc
 */

/**
 * Provides functions for sending statuses and querying Buffer's API.
 *
 * @package WP_To_Social_Pro
 * @author  WP Zinc
 * @version 3.0.0
 */
class WP_To_Social_Pro_Buffer_API {

	/**
	 * Holds the base class object.
	 *
	 * @since   3.4.7
	 *
	 * @var     object.
	 */
	public $base;

	/**
	 * Holds the Buffer Application's Client ID
	 *
	 * @since   3.3.3
	 *
	 * @var     string.
	 */
	private $client_id = 'd1Lk26lma4iEgb-20v1BWmdKrlopiGwP9pu9ri7JG0e';

	/**
	 * Holds the oAuth Authorize URL
	 *
	 * @since   6.0.0
	 *
	 * @var     string.
	 */
	private $oauth_authorize_url = 'https://auth.buffer.com/';

	/**
	 * Holds the oAuth Gateway endpoint, used to exchange a code for an access token
	 *
	 * @since   3.3.3
	 *
	 * @var     string.
	 */
	private $redirect_uri = 'https://www.wpzinc.com/?oauth=bufferv2';

	/**
	 * Holds the Proxy endpoint, which might be used to pass requests through
	 *
	 * @since   4.2.1
	 *
	 * @var     string.
	 */
	private $proxy_endpoint = 'https://proxy.wpzinc.net/';

	/**
	 * Holds the API endpoint
	 *
	 * @since   3.4.7
	 *
	 * @var     string.
	 */
	private $api_endpoint = 'https://api.buffer.com/';

	/**
	 * Access Token
	 *
	 * @since   3.0.0
	 *
	 * @var     string
	 */
	public $access_token = '';

	/**
	 * Refresh Token
	 *
	 * @since   3.4.7
	 *
	 * @var     string
	 */
	public $refresh_token = '';

	/**
	 * Token Expiry Timestamp
	 *
	 * @since   3.5.0
	 *
	 * @var     int
	 */
	public $token_expires = false;

	/**
	 * Constructor
	 *
	 * @since   3.4.7
	 *
	 * @param   object $base    Base Plugin Class.
	 */
	public function __construct( $base ) {

		// Store base class.
		$this->base = $base;

		add_action( 'wp_to_buffer_output_auth', array( $this, 'output_oauth' ) );
		add_action( 'wp_to_buffer_pro_output_auth', array( $this, 'output_oauth' ) );

	}

	/**
	 * Outputs an Authorize Plugin button on Settings > General when the Plugin needs to be authenticated with Buffer.
	 *
	 * @since   4.2.0
	 */
	public function output_oauth() {

		?>
		<div class="wpzinc-option">
			<div class="full">
				<a href="<?php echo esc_attr( $this->get_oauth_url() ); ?>" class="button button-primary">
					<?php esc_html_e( 'Connect a Buffer Account', 'wp-to-social-pro' ); ?>
				</a>
			</div>
		</div>
		<?php

	}

	/**
	 * Generates and stores a code verifier for PKCE authentication flow.
	 *
	 * @since   6.0.0
	 *
	 * @return  string
	 */
	private function generate_and_store_code_verifier() {

		// If a code verifier already exists, use it.
		$code_verifier = $this->get_code_verifier();
		if ( $code_verifier ) {
			return $code_verifier;
		}

		// Generate a random string.
		$code_verifier = random_bytes( 64 );

		// Encode to Base64 string.
		$code_verifier = $this->base64_urlencode( $code_verifier );

		// Store in database for later use.
		update_option( 'wp_to_buffer_pro_code_verifier', $code_verifier );

		// Return.
		return $code_verifier;

	}

	/**
	 * Base64URL the given code verifier, as PHP has no built in function for this.
	 *
	 * @since   6.0.0
	 *
	 * @param   string $code_verifier  Code Verifier.
	 * @return  string                  Code Challenge.
	 */
	public function generate_code_challenge( $code_verifier ) {

		// Hash using S256.
		$code_challenge = hash( 'sha256', $code_verifier, true );

		// Encode to Base64 string.
		$code_challenge = base64_encode( $code_challenge ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions

		// Convert Base64 to Base64URL by replacing “+” with “-” and “/” with “_”.
		$code_challenge = strtr( $code_challenge, '+/', '-_' );

		// Remove padding character from the end of line.
		$code_challenge = rtrim( $code_challenge, '=' );

		// Return.
		return $code_challenge;

	}

	/**
	 * Returns the stored code verifier generated by generate_and_store_code_verifier().
	 *
	 * @since   6.0.0
	 *
	 * @return  bool|string
	 */
	public function get_code_verifier() {

		return get_option( 'wp_to_buffer_pro_code_verifier' );

	}

	/**
	 * Deletes the stored code verifier generated by generate_code_verifier().
	 *
	 * @since   6.0.0
	 *
	 * @return  bool
	 */
	private function delete_code_verifier() {

		return delete_option( 'wp_to_buffer_pro_code_verifier' );

	}

	/**
	 * Base64URL encode the given string.
	 *
	 * @since   6.0.0
	 *
	 * @param   string $str    String to encode.
	 * @return  string         Encoded string.
	 */
	public function base64_urlencode( $str ) {

		// Encode to Base64 string.
		$str = base64_encode( $str ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions

		// Convert Base64 to Base64URL by replacing “+” with “-” and “/” with “_”.
		$str = strtr( $str, '+/', '-_' );

		// Remove padding character from the end of line.
		$str = rtrim( $str, '=' );

		return $str;

	}

	/**
	 * Returns the oAuth 2 URL used to begin the oAuth process
	 *
	 * @since   3.3.3
	 *
	 * @param   string $account_id     Existing Account ID (if included, is passed in the `state` parameter and ultimately back to the WordPress site to replace an account later on).
	 * @return  string  oAuth URL
	 */
	public function get_oauth_url( $account_id = '' ) {

		// Generate and store code verifier and challenge.
		$code_verifier  = $this->generate_and_store_code_verifier();
		$code_challenge = $this->generate_code_challenge( $code_verifier );

		// Generate return URL.
		$return_url = admin_url( 'admin.php?page=' . $this->base->plugin->name . '-settings' );
		if ( ! empty( $account_id ) ) {
			$return_url = add_query_arg(
				array(
					'account_id' => $account_id,
				),
				$return_url
			);
		}

		// Build args.
		$args = array(
			'client_id'             => $this->client_id,
			'redirect_uri'          => $this->redirect_uri,
			'response_type'         => 'code',
			'scope'                 => 'posts:write posts:read ideas:read ideas:write account:read account:write offline_access',
			'state'                 => rawurlencode( $return_url ),
			'code_challenge'        => $code_challenge,
			'code_challenge_method' => 'S256',
			'prompt'                => 'consent',
		);

		// Return OAuth URL.
		return add_query_arg(
			$args,
			$this->oauth_authorize_url . 'auth'
		);

	}

	/**
	 * Returns the Buffer URL where the user can register for a Buffer account
	 *
	 * @since   4.6.4
	 *
	 * @return  string  URL
	 */
	public function get_registration_url() {

		return 'https://join.buffer.com/wpzinc';

	}

	/**
	 * Returns the Buffer URL where the user can connect their social media accounts
	 * to Buffer
	 *
	 * @since   3.8.4
	 *
	 * @return  string  URL
	 */
	public function get_connect_profiles_url() {

		return 'https://publish.buffer.com/settings/channels';

	}

	/**
	 * Returns the Buffer URL where the user can change the timezone for the
	 * given profile ID.
	 *
	 * @since   3.8.1
	 *
	 * @param   string $profile_id     Profile ID.
	 * @return  string                  Timezone Settings URL
	 */
	public function get_timezone_settings_url( $profile_id ) {

		return 'https://publish.buffer.com/profile/' . $profile_id . '/tab/settings/postingSchedule';

	}

	/**
	 * Sets this class' access and refresh tokens
	 *
	 * @since   1.0.0
	 *
	 * @param   string   $access_token    Access Token.
	 * @param   string   $refresh_token   Refresh Token.
	 * @param   bool|int $token_expires   Token Expiry.
	 */
	public function set_tokens( $access_token = '', $refresh_token = '', $token_expires = false ) {

		$this->access_token  = $access_token;
		$this->refresh_token = $refresh_token;
		$this->token_expires = $token_expires;

	}

	/**
	 * Exchanges the given code for an access token, refresh token and other data.
	 *
	 * @since   6.0.0
	 *
	 * @param   string $authorization_code     Authorization Code, returned from get_oauth_url() flow.
	 * @return  WP_Error|array
	 */
	public function get_access_token( $authorization_code ) {

		$result = $this->oauth_request(
			$this->oauth_authorize_url . 'token',
			array(
				'client_id'     => $this->client_id,
				'grant_type'    => 'authorization_code',
				'code'          => $authorization_code,
				'redirect_uri'  => $this->redirect_uri,
				'code_verifier' => $this->get_code_verifier(),
			)
		);

		// Delete code verifier, as it's no longer needed.
		// If the access token request fails, the user
		// will begin the process again, which generates a
		// new code verifier.
		$this->delete_code_verifier();

		// If an error occured, return it now.
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// Update the access and refresh tokens in this class.
		$this->set_tokens( $result['access_token'], $result['refresh_token'], strtotime( '+' . $result['expires_in'] . ' seconds' ) );

		// Return data.
		return array(
			'access_token'  => $this->access_token,
			'refresh_token' => $this->refresh_token,
			'token_expires' => $this->token_expires,
		);

	}

	/**
	 * Fetches a new access token using the supplied refresh token.
	 *
	 * @since   2.0.0
	 */
	public function refresh_token() {

		// Bail if no refresh token is available to use, otherwise we'll
		// send a request to Buffer with an empty refresh_token, which
		// will fail.
		if ( empty( $this->refresh_token ) ) {
			return new WP_Error(
				'wp_to_buffer_pro_api_refresh_token_error',
				__( 'No refresh token available; cannot refresh access token.', 'wp-to-social-pro' )
			);
		}

		$result = $this->oauth_request(
			$this->oauth_authorize_url . 'token',
			array(
				'client_id'     => $this->client_id,
				'grant_type'    => 'refresh_token',
				'refresh_token' => $this->refresh_token,
			)
		);

		// If an error occured, log and return it now.
		if ( is_wp_error( $result ) ) {
			/**
			 * Perform any actions when refreshing an expired access token fails.
			 *
			 * @since   6.0.0
			 *
			 * @param   WP_Error  $result        Error from API.
			 * @param   string    $client_id     OAuth Client ID.
			 * @param   string    $access_token  Access Token.
			 * @param   string    $refresh_token Refresh Token.
			 */
			do_action( 'wp_to_buffer_pro_api_refresh_token_error', $result, $this->client_id, $this->access_token, $this->refresh_token );

			return $result;
		}

		// Build result data.
		$result = array(
			'access_token'  => $result['access_token'],
			'refresh_token' => $result['refresh_token'],
			'token_expires' => strtotime( '+' . $result['expires_in'] . ' seconds' ),
		);

		/**
		 * Perform any actions with the new access token, such as saving it.
		 *
		 * @since   6.0.0
		 *
		 * @param   array   $result                  New Access Token, Refresh Token and Expiry timestamp.
		 * @param   string  $client_id               OAuth Client ID.
		 * @param   string  $previous_access_token   Existing Access Token.
		 * @param   string  $previous_refresh_token  Existing Refresh Token.
		 */
		do_action( 'wp_to_buffer_pro_api_refresh_token', $result, $this->client_id, $this->access_token, $this->refresh_token );

		// Update the access and refresh tokens in this class.
		$this->set_tokens( $result['access_token'], $result['refresh_token'], $result['token_expires'] );

		// Return new access token, refresh token and expiry timestamp.
		return $result;

	}

	/**
	 * Returns the organizations the current account is a member of.
	 *
	 * @since   6.0.1
	 *
	 * @param   bool $force                      Force API call (false = use WordPress transient).
	 * @param   int  $transient_expiration_time  Transient Expiration Time, in seconds (default: 12 hours).
	 *
	 * @return  WP_Error|array
	 */
	public function organizations( $force = false, $transient_expiration_time = 43200 ) {

		$organizations = array();

		// Check if our WordPress transient already has this data.
		// This reduces the number of times we query the API.
		$organizations = get_transient( $this->base->plugin->name . '_buffer_api_organizations' );
		if ( $force || false === $organizations ) {
			// Build GraphQL query.
			$query = '
query {
	account {
		organizations {
		id
		name
		ownerEmail
			limits {
				channels
				scheduledPosts
			}
		}
	}
}';

			// Query API.
			$result = $this->graphql_query( $query );

			// Bail if an error occurred.
			if ( is_wp_error( $result ) ) {
				return $result;
			}

			foreach ( $result['data']['account']['organizations'] as $organization ) {
				$organizations[ $organization['id'] ] = array(
					'id'            => $organization['id'],
					'name'          => $organization['name'],
					'email'         => $organization['ownerEmail'],
					'channel_limit' => $organization['limits']['channels'],
					'plan'          => $organization['limits']['scheduledPosts'] === 10 ? 'free' : 'paid',
				);
			}

			// Store organizations in transient.
			set_transient( $this->base->plugin->name . '_buffer_api_organizations', $organizations, $transient_expiration_time );
		}

		return $organizations;

	}

	/**
	 * Returns the account within the organization with the given ID.
	 *
	 * @since   6.0.0
	 *
	 * @param   string $account_id     Account ID.
	 * @return  WP_Error|array
	 */
	public function account( $account_id = '' ) {

		$organizations = $this->organizations( false, $this->base->get_class( 'common' )->get_transient_expiration_time() );

		// Bail if an error occurred.
		if ( is_wp_error( $organizations ) ) {
			return $organizations;
		}

		// Bail if the organization is not found.
		if ( ! array_key_exists( $account_id, $organizations ) ) {
			return new WP_Error( 'organization_not_found', __( 'Organization not found.', 'wp-to-social-pro' ) );
		}

		return $organizations[ $account_id ];

	}

	/**
	 * Returns a list of Social Media Profiles attached to the Buffer Account.
	 *
	 * Profiles are cached in a non-autoloaded option. Persistence survives
	 * object cache eviction. Callers force a refresh via the Refresh Profiles
	 * button in Settings > Authentication.
	 *
	 * @since   3.0.0
	 *
	 * @param   bool   $force        Force API call (false = use stored option).
	 * @param   string $account_id   Account ID.
	 * @return  WP_Error|array
	 */
	public function profiles( $force = false, $account_id = 'default' ) {

		// Return stored profiles if available and not forcing a refresh.
		$option_name = $this->base->plugin->name . '-profiles-' . $account_id;
		$profiles    = get_option( $option_name );
		if ( ! $force && is_array( $profiles ) ) {
			return $profiles;
		}

		// Build GraphQL query.
		$query = '
query GetChannels($organizationId: OrganizationId!) {
    channels(input: { organizationId: $organizationId }) {
        id
        descriptor
        name
        service
        serviceId
        timezone
        metadata {
            ... on PinterestMetadata {
                boards {
                    id
					serviceId
                    name
                }
            }
        }
    }
}';

		// Get profiles.
		$results = $this->graphql_query(
			$query,
			array(
				'organizationId' => $account_id,
			)
		);

		// Check for errors.
		if ( is_wp_error( $results ) ) {
			return $results;
		}

		// Build profiles array from results.
		$profiles = array();
		foreach ( $results['data']['channels'] as $channel ) {
			$profiles[ $channel['id'] ] = array(
				'id'                 => $channel['id'], // Buffer ID. Buffer uses this for the ID when creating a post.
				'formatted_service'  => $channel['descriptor'],
				'formatted_username' => $channel['name'],
				'service'            => $channel['service'],
				'timezone'           => $channel['timezone'],
				'can_be_subprofile'  => false, // For pinterest, the profile is the account, not the board.
			);

			// Pinterest: Add Boards as Subprofiles.
			switch ( $channel['service'] ) {
				case 'pinterest':
					$profiles[ $channel['id'] ]['subprofiles'] = array();
					foreach ( $channel['metadata']['boards'] as $board ) {
						$profiles[ $channel['id'] ]['subprofiles'][ $board['serviceId'] ] = array(
							'id'      => $board['serviceId'], // Social Network (Pinterest) ID. Buffer uses this for the ID when creating a Pin. Yes, it's different from ['id'] above.
							'name'    => $board['name'],
							'service' => $channel['service'],
						);
					}
					break;
			}
		}

		// Store profiles in a non-autoloaded option so they persist across
		// object cache eviction, and don't load on every WP request.
		update_option( $option_name, $profiles, false );

		return $profiles;

	}

	/**
	 * Returns a single post by its ID.
	 *
	 * @since   6.0.0
	 *
	 * @param   string $post_id    Post ID.
	 * @return  WP_Error|array
	 */
	public function get_post( $post_id ) {

		$query = '
	query GetPost($postId: PostId!) {
		post(input: { id: $postId }) {
			id
			status
			text
			metadata {
				... on FacebookPostMetadata {
					annotations {
					    type
						content
						indices
						text
						url
					}
				}
			}
		}
	}';

		$result = $this->graphql_query(
			$query,
			array(
				'postId' => $post_id,
			)
		);

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return $result['data']['post'];

	}

	/**
	 * Creates an update (status)
	 *
	 * @since   3.0.0
	 *
	 * @param   array  $params     Params.
	 * @param   string $service    Service.
	 * @return  WP_Error|array
	 */
	public function updates_create( $params, $service ) {

		// Build GraphQL variables.
		// assets defaults to an empty array; Buffer's schema requires a
		// non-null list even for text-only posts, so we always send this key.
		$variables = array(
			'channelId'      => $params['profile_ids'][0],
			'text'           => $params['text'],
			'schedulingType' => 'automatic',
			'mode'           => 'addToQueue',
			'source'         => 'wp-buffer',
			'assets'         => array(),
		);
		$assets    = array();

		// Scheduling.
		switch ( $params['schedule_type'] ) {
			case 'queue_end':
				$variables['mode'] = 'addToQueue';
				break;

			case 'queue_start':
				$variables['mode'] = 'shareNext';
				break;

			case 'immediate':
				$variables['mode'] = 'shareNow';
				break;

			default:
				// If no scheduled_at is set, add to end of queue.
				if ( ! array_key_exists( 'scheduled_at', $params ) ) {
					$variables['mode'] = 'addToQueue';
					break;
				}
				if ( empty( $params['scheduled_at'] ) ) {
					$variables['mode'] = 'addToQueue';
					break;
				}

				$variables['mode']  = 'customScheduled';
				$variables['dueAt'] = gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $params['scheduled_at'] ) );
				break;
		}

		// Draft.
		if ( array_key_exists( 'is_draft', $params ) ) {
			$variables['saveToDraft'] = (bool) $params['is_draft'];
		}

		// Metadata.
		$metadata = array();
		switch ( $service ) {

			case 'instagram':
				$metadata = array(
					'type'              => $params['post_type'] === 'story' ? 'story' : 'post',
					'shouldShareToFeed' => true,
				);

				// First Comment.
				if ( ! empty( $params['first_comment'] ) ) {
					$metadata['firstComment'] = $params['first_comment'];
				}

				// Shop Grid Link.
				if ( ! empty( $params['url'] ) ) {
					$metadata['link'] = $params['url'];
				}
				break;

			case 'facebook':
				$metadata = array(
					'type' => 'post',
				);

				// First Comment.
				if ( ! empty( $params['first_comment'] ) ) {
					$metadata['firstComment'] = $params['first_comment'];
				}

				// OpenGraph / Link Attachment.
				if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
					$assets = array(
						array(
							'link' => array(
								'url' => $params['url'],
							),
						),
					);
				}

				// Annotations.
				if ( array_key_exists( 'annotations', $params ) ) {
					$metadata['annotations'] = $params['annotations'];
				}
				break;

			case 'linkedin':
				// First Comment.
				if ( ! empty( $params['first_comment'] ) ) {
					$metadata['firstComment'] = $params['first_comment'];
				}

				// OpenGraph / Link Attachment.
				if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
					$assets = array(
						array(
							'link' => array(
								'url' => $params['url'],
							),
						),
					);
				}

				// Annotations.
				if ( array_key_exists( 'annotations', $params ) ) {
					$metadata['annotations'] = $params['annotations'];
				}
				break;

			case 'twitter':
				// First Comment.
				if ( ! empty( $params['first_comment'] ) ) {
					$metadata = array(
						'thread' => array(
							array(
								'text' => $params['first_comment'],
							),
						),
					);
				}

				// OpenGraph / Link Attachment.
				if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
					$variables['text'] .= ' ' . $params['url'];
				}
				break;

			case 'pinterest':
				$metadata = array(
					'title'          => $params[ $service ]['title'],
					'url'            => $params['url'],
					'boardServiceId' => $params[ $service ]['board'],
				);
				break;

			case 'googlebusiness':
				// Post Type.
				$metadata = array(
					'type' => $params[ $service ]['post_type'],
				);

				// Depending on the Post Type, add the appropriate metadata.
				switch ( $metadata['type'] ) {
					case 'whats_new':
						$metadata['detailsWhatsNew'] = array(
							'button' => ! empty( $params[ $service ]['cta'] ) ? $params[ $service ]['cta'] : 'none',
							'link'   => $params['url'],
						);
						break;
					case 'event':
						$metadata['detailsEvent'] = array(
							'title'          => $params[ $service ]['title'],
							'startDate'      => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['start_date'] ),
							'endDate'        => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['end_date'] ),
							'isFullDayEvent' => false,
							'button'         => ! empty( $params[ $service ]['cta'] ) ? $params[ $service ]['cta'] : 'none',
							'link'           => $params['url'],
						);
						break;

					case 'offer':
						$metadata['detailsOffer'] = array(
							'title'     => $params[ $service ]['title'],
							'startDate' => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['start_date'] ),
							'endDate'   => gmdate( 'Y-m-d\TH:i:s\Z', $params[ $service ]['end_date'] ),
							'code'      => $params[ $service ]['code'],
							'link'      => $params['url'],
							'terms'     => $params[ $service ]['terms'],
						);
						break;
				}
				break;

			case 'threads':
				// OpenGraph / Link Attachment.
				if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
					$assets = array(
						array(
							'link' => array(
								'url' => $params['url'],
							),
						),
					);
				}

				// First Comment.
				if ( ! empty( $params['first_comment'] ) ) {
					$metadata = array(
						'thread' => array(
							array(
								'text' => $params['first_comment'],
							),
						),
					);
				}
				break;

			case 'bluesky':
				// OpenGraph / Link Attachment.
				if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
					$assets = array(
						array(
							'link' => array(
								'url' => $params['url'],
							),
						),
					);
				}

				// First Comment.
				if ( ! empty( $params['first_comment'] ) ) {
					$metadata = array(
						'thread' => array(
							array(
								'text' => $params['first_comment'],
							),
						),
					);
				}
				break;

			case 'mastodon':
				// First Comment.
				if ( ! empty( $params['first_comment'] ) ) {
					$metadata = array(
						'thread' => array(
							array(
								'text' => $params['first_comment'],
							),
						),
					);
				}

				// OpenGraph / Link Attachment.
				if ( $params['post_type'] === 'link' && ! empty( $params['url'] ) ) {
					$variables['text'] .= ' ' . $params['url'];
				}
				break;
		}

		// Add metadata.
		if ( ! empty( $metadata ) ) {
			$variables['metadata'] = array(
				( $service === 'googlebusiness' ? 'google' : $service ) => $metadata,
			);
		}

		// Assets / Images.
		switch ( $params['post_type'] ) {
			case 'image':
			case 'story':
			case 'pin':
			case 'googlebusiness':
				// Bail if no images are defined.
				if ( ! array_key_exists( 'media_urls', $params ) ) {
					break;
				}

				// Build assets array.
				$assets = array();
				foreach ( $params['media_urls'] as $media ) {
					$assets[] = array(
						'image' => array(
							'url'          => $media['image'],
							'thumbnailUrl' => $media['thumbnail'],
							'metadata'     => array(
								'altText'    => $media['alt_text'],
								'dimensions' => array(
									'width'  => $media['width'],
									'height' => $media['height'],
								),
							),
						),
					);
				}
				break;
		}

		// Include assets. Always overwrites the default empty array
		// initialised above, if the service branch built any.
		if ( ! empty( $assets ) ) {
			$variables['assets'] = $assets;
		}

		// Build GraphQL query.
		$query = '
mutation CreatePost(
    $channelId: ChannelId!
    $text: String
    $schedulingType: SchedulingType!
    $mode: ShareMode!
    $source: String
    $dueAt: DateTime
    $saveToDraft: Boolean
    $assets: [AssetInput!]!
    $metadata: PostInputMetaData
) {
    createPost(input: {
        channelId: $channelId
        text: $text
        schedulingType: $schedulingType
        mode: $mode
        source: $source
        dueAt: $dueAt
        saveToDraft: $saveToDraft
        assets: $assets
        metadata: $metadata
    }) {
        ... on PostActionSuccess {
            post {
                id
                text
                status
                dueAt
            }
        }
        ... on MutationError {
            message
        }
    }
}';

		// Send update.
		$result = $this->graphql_query( $query, $variables );

		// Bail if the result is an error.
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// Return array of just the data we need to send to the Plugin.
		return array(
			'profile_id'        => $params['profile_ids'][0], // API doesn't return the Profile ID.
			'message'           => $result['data']['createPost']['post']['status'],
			'status_text'       => $result['data']['createPost']['post']['text'],

			// Both must be UTC timestamps.
			'status_created_at' => strtotime( 'now' ),
			// due_at won't exist if is_draft = 'true' when the update was created.
			'due_at'            => ( isset( $result['data']['createPost']['post']['dueAt'] ) ? strtotime( $result['data']['createPost']['post']['dueAt'] ) : '0000-00-00 00:00:00' ),
		);

	}

	/**
	 * Main function for fetching access tokens and refreshing existing tokens.
	 *
	 * All requests are sent using POST, as the Buffer API uses GraphQL:
	 * https://developers.buffer.com/guides/rest-migration.html
	 *
	 * @since   3.0.0
	 *
	 * @param   string $url     URL.
	 * @param   array  $params  Parameters (optional).
	 * @return  WP_Error|array
	 */
	private function oauth_request( $url, $params = array() ) {

		// Send request.
		$result = wp_remote_post(
			$url,
			array(
				'headers'    => array(
					'Accept'       => 'application/json',
					'Content-Type' => 'application/x-www-form-urlencoded',
				),
				'body'       => $this->get_body( $params, 'application/x-www-form-urlencoded' ),
				'timeout'    => $this->get_timeout(),
				'user-agent' => $this->get_user_agent(),
			)
		);

		// If an error occured, return it now.
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// Parse response and return.
		return $this->parse_response( $result );

	}

	/**
	 * Main function which handles sending requests to Buffer API's
	 * GraphQL endpoints.
	 *
	 * All requests are sent using POST, as the Buffer API uses GraphQL:
	 * https://developers.buffer.com/guides/rest-migration.html
	 *
	 * @since   6.0.0
	 *
	 * @param   string $query         GraphQL Query.
	 * @param   array  $variables     GraphQL Variables.
	 * @param   bool   $is_retry      Whether this is a retry following a token refresh.
	 * @return  WP_Error|array
	 */
	private function graphql_query( $query, $variables = array(), $is_retry = false ) {

		// Build body.
		$body = array( 'query' => $query );
		if ( ! empty( $variables ) ) {
			$body['variables'] = $variables;
		}

		// Send request.
		$result = wp_remote_post(
			$this->api_endpoint,
			array(
				'headers'    => $this->get_request_headers( 'application/json' ),
				'body'       => $this->get_body( $body, 'application/json' ),
				'timeout'    => $this->get_timeout(),
				'user-agent' => $this->get_user_agent(),
			)
		);

		// If an error occured, return it now.
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// Parse result.
		$response = $this->parse_response( $result );

		// If this is a retry, return the parsed response.
		// This prevents an infinite loop of retries when a token refresh fails.
		if ( $is_retry ) {
			return $response;
		}

		// If the request was successful, return the response.
		if ( ! is_wp_error( $response ) ) {
			return $response;
		}

		// If the error isn't an unauthenticated error, return it.
		if ( strtolower( $response->get_error_code() ) !== 'unauthenticated' ) {
			return $response;
		}

		// Attempt to refresh the token.
		$refresh_result = $this->refresh_token();

		// Bail if the refresh token attempt failed.
		if ( is_wp_error( $refresh_result ) ) {
			return $refresh_result;
		}

		// Attempt the request again, now we have a new access token.
		return $this->graphql_query( $query, $variables, true );

	}

	/**
	 * Returns the headers to use in an authenticated GraphQL API request.
	 *
	 * @param string $type Accept and Content-Type Headers.
	 *
	 * @since 6.0.0
	 *
	 * @return array
	 */
	private function get_request_headers( $type = 'application/json' ) {

		$headers = array(
			'Accept'       => 'application/json',
			'Content-Type' => $type,
		);

		// Add authorization header and return.
		if ( $this->access_token ) {
			$headers['Authorization'] = 'Bearer ' . $this->access_token;
		}

		return $headers;

	}

	/**
	 * Returns the body to use in an API request.
	 *
	 * @param array  $params Parameters.
	 * @param string $content_type Content Type.
	 *
	 * @since 6.0.0
	 *
	 * @return string
	 */
	private function get_body( $params = array(), $content_type = 'application/json' ) {

		return ( $content_type === 'application/x-www-form-urlencoded' ? http_build_query( $params ) : wp_json_encode( $params ) );

	}

	/**
	 * Returns the maximum amount of time to wait for
	 * a response to the request before exiting.
	 *
	 * @since   1.0.0
	 *
	 * @return  int     Timeout, in seconds.
	 */
	private function get_timeout() {

		$timeout = 10;

		/**
		 * Defines the maximum time to allow the API request to run.
		 *
		 * @since   1.0.0
		 *
		 * @param   int     $timeout    Timeout, in seconds.
		 */
		$timeout = apply_filters( 'wp_to_buffer_pro_api_get_timeout', $timeout );

		return $timeout;

	}

	/**
	 * Gets a customized version of the WordPress default user agent.
	 *
	 * @since   6.0.0
	 *
	 * @return string User Agent
	 */
	private function get_user_agent() {

		return sprintf(
			'%1$s/%2$s (WordPress/%3$s; PHP/%4$s)',
			$this->base->plugin->name,
			$this->base->plugin->version,
			get_bloginfo( 'version' ),
			PHP_VERSION
		);

	}

	/**
	 * Parses the response body, returning a WP_Error
	 * if the response body contains an error.
	 *
	 * @since   3.9.8
	 *
	 * @param   string $response   Response Body.
	 * @return  WP_Error|array
	 */
	private function parse_response( $response ) {

		// Get HTTP code and body.
		$http_code = wp_remote_retrieve_response_code( $response );
		$http_body = wp_remote_retrieve_body( $response );

		// Handle HTTP errors.
		switch ( $http_code ) {
			case 403:
				return new WP_Error(
					'buffer_api_error',
					'403 Forbidden'
				);
		}

		// Decode response.
		$body = json_decode( $http_body, true );

		// If an error is detected, return it.
		if ( array_key_exists( 'error', $body ) ) {
			return new WP_Error(
				$body['error'],
				$body['error_description']
			);
		}

		// If multiple errors are detected, return them.
		if ( array_key_exists( 'errors', $body ) ) {
			// If the access token begins with '2/', it's from the old API.
			if ( strpos( $this->access_token, '2/' ) === 0 ) {
				return new WP_Error(
					'buffer_api_error',
					sprintf(
						/* translators: %1$s: Plugin Name, %2$s: Plugin Name */
						__( '%1$s uses a new API. Please click the `Reconnect` button at %2$s Settings > Authentication to reconnect your account. You won\'t need to do this again.', 'wp-to-social-pro' ),
						$this->base->plugin->displayName,
						$this->base->plugin->displayName
					)
				);
			}

			return new WP_Error(
				$body['errors'][0]['extensions']['code'],
				$body['errors'][0]['message']
			);
		}

		// Check for mutation-level errors inside data.
		// Mutation responses are nested under data.{operationName}.
		// Success responses contain specific keys (e.g. 'post', 'idea').
		// Error responses contain only 'message'.
		if ( isset( $body['data'] ) && is_array( $body['data'] ) ) {
			foreach ( $body['data'] as $operation => $result ) {
				if ( is_array( $result )
					&& isset( $result['message'] )
					&& count( $result ) === 1
				) {
					return new WP_Error(
						'buffer_api_error',
						$this->provide_verbose_error_message( $result['message'] )
					);
				}
			}
		}

		return $body;

	}

	/**
	 * Provides a more actionable error message, based on the error message supplied
	 * from the Buffer API MutationError.
	 *
	 * @since   6.0.0
	 *
	 * @param   string $message Error Message.
	 * @return  string
	 */
	private function provide_verbose_error_message( $message ) {

		switch ( $message ) {
			case 'Invalid post: First comment requires a paid plan. Please upgrade to use this feature.':
				return 'Your buffer.com plan does not support first comments. Please upgrade to a paid plan on buffer.com to use this feature.';

			default:
				return $message;
		}

	}

}
