<?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 = '592d41d14d97ab7e4e571edb';

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

	/**
	 * 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.bufferapp.com/';

	/**
	 * Holds the API version
	 *
	 * @since   3.4.7
	 *
	 * @var     string.
	 */
	private $api_version = '1';

	/**
	 * 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

	}

	/**
	 * Returns the oAuth 2 URL used to begin the oAuth process
	 *
	 * @since   3.3.3
	 *
	 * @return  string  oAuth URL
	 */
	public function get_oauth_url() {

		return 'https://bufferapp.com/oauth2/authorize?client_id=' . $this->client_id . '&redirect_uri=' . rawurlencode( $this->oauth_gateway_endpoint ) . '&response_type=code&state=' . rawurlencode( admin_url( 'admin.php?page=' . $this->base->plugin->name . '-settings' ) );

	}

	/**
	 * 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://login.buffer.com/signup?product=publish&plan=free';

	}

	/**
	 * 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://account.buffer.com/channels/connect';

	}

	/**
	 * 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   3.4.0
	 *
	 * @param   string $access_token    Access Token.
	 * @param   string $refresh_token   Refresh Token.
	 * @param   mixed  $token_expires   Token Expires (false | timestamp).
	 */
	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;

	}

	/**
	 * Returns the User object
	 *
	 * @since   3.0.0
	 *
	 * @return  mixed   WP_Error | User object
	 */
	public function user() {

		return $this->get( 'user.json' );

	}

	/**
	 * Returns the account ID and name for the current account.
	 *
	 * @since   5.4.0
	 *
	 * @return  WP_Error|array
	 */
	public function account_id_and_name() {

		// Query API.
		$account = $this->get( 'user.json' );

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

		// Return the account ID and name.
		return array(
			'id'   => $account->id,
			'name' => isset( $account->name ) ? $account->name : $account->id,
		);

	}

	/**
	 * Returns a list of Social Media Profiles attached to the Buffer Account.
	 *
	 * @since   3.0.0
	 *
	 * @param   bool   $force                      Force API call (false = use WordPress transient).
	 * @param   int    $transient_expiration_time  Transient Expiration Time, in seconds (default: 12 hours).
	 * @param   string $account_id                 Account ID.
	 * @return  WP_Error|array
	 */
	public function profiles( $force = false, $transient_expiration_time = 43200, $account_id = 'default' ) {

		// Setup profiles array.
		$profiles = array();

		// Check if our WordPress transient already has this data.
		// This reduces the number of times we query the API.
		$profiles = get_transient( $this->base->plugin->name . '_buffer_api_profiles_' . $account_id );
		if ( $force || false === $profiles ) {
			// Get profiles.
			$results = $this->get( 'profiles.json?subprofiles=1' );

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

			// Define profiles as a blank array.
			$profiles = array();

			// Check data is valid.
			foreach ( $results as $result ) {
				// Add profile to array.
				$profiles[ $result->id ] = array(
					'id'                 => $result->id, // Buffer ID.
					'social_network_id'  => $result->service_id, // Social Network (e.g. FB, Twitter) ID.
					'formatted_service'  => $result->formatted_service,
					'formatted_username' => ( $result->service !== 'twitter' ? $result->formatted_username : '' ),
					'service'            => $result->service,
					'timezone'           => $result->timezone,
					'can_be_subprofile'  => false, // For pinterest, the profile is the account, not the board.
				);

				// Twitter's 2019 Developer Policies mean that the formatted username and profile image are no longer returned.
				// In turn, Buffer cannot provide this information, so we must directly query for it through the Twitter API.
				if ( $result->service === 'twitter' && empty( $profiles[ $result->id ]['formatted_username'] ) ) {
					// Fetch Twitter username from the API.
					// The API class will check the transient first and use cached results if available.
					$twitter_username = $this->base->get_class( 'twitter_api' )->get_username_by_id( $profiles[ $result->id ]['social_network_id'], $transient_expiration_time );
					if ( is_wp_error( $twitter_username ) ) {
						continue;
					}

					// Add username to results.
					$profiles[ $result->id ]['formatted_username'] = $twitter_username;
				}

				// Pinterest: Add subprofiles.
				if ( $result->service === 'pinterest' ) {
					$profiles[ $result->id ]['subprofiles'] = array();

					if ( isset( $result->subprofiles ) && count( $result->subprofiles ) > 0 ) {
						foreach ( $result->subprofiles as $sub_profile ) {
							$profiles[ $result->id ]['subprofiles'][ $sub_profile->id ] = array(
								'id'      => $sub_profile->id,
								'name'    => $sub_profile->name,
								'service' => $sub_profile->service,
							);
						}
					}
				}
			}

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

		// Return results.
		return $profiles;

	}

	/**
	 * Returns an individual update (status) for the given ID
	 *
	 * @since   4.5.6
	 *
	 * @param   string $id     Update ID.
	 * @return  mixed           WP_Error | Update object
	 */
	public function updates_get( $id ) {

		// Check access token.
		if ( ! $this->check_access_token_exists() ) {
			return false;
		}

		return $this->get( '/updates/' . $id . '.json' );

	}

	/**
	 * Returns an array of status update(s) that are queued for the given Profile ID
	 *
	 * @since   4.5.6
	 *
	 * @param   string $profile_id     Profile ID.
	 * @return  mixed                   WP_Error | Updates array
	 */
	public function profiles_updates_pending( $profile_id ) {

		// Check access token.
		if ( ! $this->check_access_token_exists() ) {
			return false;
		}

		return $this->get( '/profiles/' . $profile_id . '/updates/pending.json' );

	}

	/**
	 * Creates an update (status)
	 *
	 * @since   3.0.0
	 *
	 * @param   array $params     Params.
	 * @return  mixed               WP_Error | Update object
	 */
	public function updates_create( $params ) {

		// Send request.
		$result = $this->post( 'updates/create.json', $params );

		// 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'        => $result->updates[0]->profile_id,
			'message'           => $result->message,
			'status_text'       => $result->updates[0]->text,
			'status_created_at' => $result->updates[0]->created_at,
			// due_at won't exist if is_draft = 'true' when the update was created.
			'due_at'            => ( isset( $result->updates[0]->due_at ) ? $result->updates[0]->due_at : '0000-00-00 00:00:00' ),
		);

	}

	/**
	 * Private function to perform a GET request
	 *
	 * @since  3.0.0
	 *
	 * @param  string $cmd        Command (required).
	 * @param  array  $params     Params (optional).
	 * @return mixed               WP_Error | object
	 */
	private function get( $cmd, $params = array() ) {

		return $this->request( $cmd, 'get', $params );

	}

	/**
	 * Private function to perform a POST request
	 *
	 * @since  3.0.0
	 *
	 * @param  string $cmd        Command (required).
	 * @param  array  $params     Params (optional).
	 * @return mixed               WP_Error | object
	 */
	private function post( $cmd, $params = array() ) {

		return $this->request( $cmd, 'post', $params );

	}

	/**
	 * Main function which handles sending requests to the Buffer API
	 *
	 * @since   3.0.0
	 *
	 * @param   string $cmd        Command.
	 * @param   string $method     Method (get|post).
	 * @param   array  $params     Parameters (optional).
	 * @return  mixed               WP_Error | object
	 */
	private function request( $cmd, $method = 'get', $params = array() ) {

		// Check required parameters exist.
		if ( empty( $this->access_token ) ) {
			return new WP_Error( 'missing_access_token', __( 'No access token was specified', 'wp-to-social-pro' ) );
		}

		// Add access token to command, depending on the command's format.
		if ( strpos( $cmd, '?' ) !== false ) {
			$cmd .= '&access_token=' . $this->access_token;
		} else {
			$cmd .= '?access_token=' . $this->access_token;
		}

		// Build endpoint URL.
		$url = $this->api_endpoint . $this->api_version . '/' . $cmd;

		// Define the timeout.
		$timeout = 20;

		/**
		 * Defines the number of seconds before timing out a request to the Buffer API.
		 *
		 * @since   3.0.0
		 *
		 * @param   int     $timeout    Timeout, in seconds
		 */
		$timeout = apply_filters( $this->base->plugin->name . '_buffer_api_request', $timeout );

		// Request via WordPress functions.
		return $this->request_wordpress( $url, $method, $params, $timeout );

	}

	/**
	 * Performs POST and GET requests through WordPress wp_remote_post() and
	 * wp_remote_get() functions
	 *
	 * @since   3.2.6
	 *
	 * @param   string $url        URL.
	 * @param   string $method     Method (post|get).
	 * @param   array  $params     Parameters.
	 * @param   int    $timeout    Timeout, in seconds (default: 10).
	 * @return  mixed               WP_Error | object
	 */
	private function request_wordpress( $url, $method, $params, $timeout = 20 ) {

		// If proxy is enabled, send the request to our proxy with the URL, method and parameters.
		if ( $this->base->get_class( 'settings' )->get_option( 'proxy', false ) ) {
			$response = wp_remote_get(
				$this->proxy_endpoint,
				array(
					'body' => array(
						'url'    => $url,
						'method' => $method,
						'params' => http_build_query( $params ),
					),
				)
			);
		} else {
			// Send request.
			switch ( $method ) {
				/**
				 * GET
				 */
				case 'get':
					$response = wp_remote_get(
						$url,
						array(
							'body'    => $params,
							'timeout' => $timeout,
						)
					);
					break;

				/**
				 * POST
				 */
				case 'post':
					$response = wp_remote_post(
						$url,
						array(
							'body'    => $params,
							'timeout' => $timeout,
						)
					);
					break;
			}
		}

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

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

		// Parse the response, to return the JSON data or an WP_Error object.
		return $this->parse_response( $response, $http_code, $params );

	}

	/**
	 * Parses the response body and HTTP code, returning either
	 * a WP_Error object or the JSON decoded response body.
	 *
	 * @since   3.9.8
	 *
	 * @param   string $response   Response Body.
	 * @param   int    $http_code  HTTP Code.
	 * @param   array  $params     Request Parameters.
	 * @return  mixed               WP_Error | object
	 */
	private function parse_response( $response, $http_code, $params ) {

		// Decode response.
		$body = json_decode( $response );

		// Return body if HTTP code is 200.
		if ( $http_code === 200 ) {
			return $body;
		}

		// Return basic WP_Error if we don't have any more information.
		if ( is_null( $body ) ) {
			return new WP_Error(
				$http_code,
				sprintf(
					/* translators: HTTP Response Code */
					__( 'Buffer API Error: HTTP Code %s. Sorry, we don\'t have any more information about this error. Please try again.', 'wp-to-social-pro' ),
					$http_code
				)
			);
		}

		// Return detailed WP_Error.
		// Define the error message.
		$message = array();
		if ( isset( $body->error ) ) {
			$message[] = $body->error;
		}
		if ( isset( $body->message ) ) {
			$message[] = $body->message;
		}

		// For certain error codes, we can provide better error messages to the user, detailing
		// the steps they should take to resolve the issue.
		switch ( $body->code ) {

			/**
			 * Unauthorized.
			 * Permission Denied.
			 * Access Token Required.
			 */
			case 401:
			case 403:
			case 1001:
				$message[] = __( 'Click the "Deauthorize Plugin" button, and then the "Authorize Plugin" button on the Plugin\'s Settings screen', 'wp-to-social-pro' );
				break;

			/**
			 * Parameter not recognized (invalid image url parameter supplied).
			 */
			case 1003:
				$message[] = __( 'Run this Plugin on a publicly accessible domain that does not have password protection.', 'wp-to-social-pro' );
				break;

			/**
			 * Featured Image Missing.
			 * Number of additional images exceeds the limit.
			 * Message too long.
			 */
			case 1004:
				$message = array(
					$body->message,
				);
				break;

			/**
			 * No authorization to access profile.
			 */
			case 1011:
				$message[] = sprintf(
					/* translators: %1$s: Social Media Account Service/Type (e.g. Facebook, Twitter), %2$s: Social Media Account Name */
					__( 'Pinterest: Choose a Pinterest board in the status settings.  Otherwise, reconnect the %1$s Account %2$s in Buffer.', 'wp-to-social-pro' ),
					$profile['formatted_service'],
					$profile['formatted_username']
				);
				break;

			/**
			 * Queue limit reached.
			 */
			case 1023:
				$message[] = sprintf(
					'<a href="https://buffer.com/pricing">%s</a> %s',
					__( 'Upgrade your Buffer plan', 'wp-to-social-pro' ),
					__( 'or change status schedule = Post Immediately in the Plugin status settings.', 'wp-to-social-pro' )
				);
				break;

			/**
			 * Duplicate update.
			 */
			case 1025:
				$message[] = __( 'Change the status text using the Per-Post Settings, to ensure it is slightly different from the last status.', 'wp-to-social-pro' );
				break;

			/**
			 * Media filetype not supported (...)
			 * The provided image does not appear to be valid i.e. is a localhost URL or invalid dimensions
			 * Whoops, so sorry! It looks like we had some trouble with your Facebook Page mentions. Would you be up for trying again?
			 */
			case 1030:
				if ( strpos( $body->message, 'Facebook Page mentions' ) !== false ) {
					$message = array(
						$body->message,
					);
				}

				if ( strpos( $body->message, 'image' ) !== false ) {
					if ( $this->is_local_host() ) {
						$message = array(
							sprintf(
								/* translators: Image URL */
								__( 'Buffer could not fetch the image %s because your site is running on a local host and not web accessible. Please run the Plugin on a publicly accessible domain.', 'wp-to-social-pro' ),
								( isset( $params['media']['picture'] ) ? $params['media']['picture'] : '' )
							),
						);
					} else {
						$message = array(
							sprintf(
								/* translators: Image URL */
								__( 'Buffer could not fetch the image `%1$s`.  Check:', 'wp-to-social-pro' ),
								( isset( $params['media']['picture'] ) ? $params['media']['picture'] : '' )
							),
							sprintf(
								/* translators: Link to Cloudflare Docs */
								__( '- For Cloudflare, that %s, or configured to allow the required image(s) to be fetched by Buffer', 'wp-to-social-pro' ),
								'<a href="https://developers.cloudflare.com/waf/tools/scrape-shield/hotlink-protection/" target="_blank">' . __( 'Hotlink Protection is disabled', 'wp-to-social-pro' ) . '</a>'
							),
							__( '- The Feature Image URL is directly accessible through your web browser, as a non-logged in WordPress User, with no force login, HTTP basic auth or fiewall / bot limiter preventing access.', 'wp-to-social-pro' ),
							sprintf(
								/* translators: Link to Media File Renamer Plugin */
								__( '- Install %s to automatically remove spaces, accented or special characters in image filenames.', 'wp-to-social-pro' ),
								'<a href="https://wordpress.org/plugins/media-file-renamer/" target="_blank">' . __( 'Media File Renamer Plugin', 'wp-to-social-pro' ) . '</a>'
							),
							sprintf(
								/* translators: Link to SSL Labs Checker */
								__( '- %s, with no warnings of chain issues / intermediate certificate failures', 'wp-to-social-pro' ),
								'<a href="https://www.ssllabs.com/ssltest/" target="_blank">' . __( 'Your site passes SSL tests', 'wp-to-social-pro' ) . '</a>'
							),
							__( 'If the issue persists, please work with Buffer to resolve.', 'wp-to-social-pro' ),
						);
					}
				}
				break;

			/**
			 * Cannot schedule updates in the past
			 */
			case 1034:
				if ( isset( $params['scheduled_at'] ) ) {
					$message[] = sprintf(
						/* translators: Scheduled Date and Time */
						__( 'The Custom Time (based on Custom Field / Post Meta Value) field cannot be %s, which is a date in the past.', 'wp-to-social-pro' ),
						$params['scheduled_at']
					);
				} else {
					$message[] = sprintf(
						/* translators: %1$s: Link to WordPress General Settings, %2$s: Link to Social Media Scheduling Timezone Settings Screen, %3$s: Social Media Scheduler Name (Buffer, Hootsuite, SocialPilot) */
						__( '<a href="%1$s">WordPress</a> and <a href="%2$s">%3$s</a> timezones must match.', 'wp-to-social-pro' ),
						admin_url( 'options-general.php' ),
						$this->get_timezone_settings_url( $params['profile_ids'][0] ),
						$this->base->plugin->account
					);
				}
				break;

		}

		// Return WP_Error.
		return new WP_Error(
			$body->code,
			sprintf(
				/* translators: %1$s: API Error Code, %2$s: API Error Message */
				__( 'Buffer API Error: #%1$s: %2$s', 'wp-to-social-pro' ),
				$body->code,
				implode( "\n", $message )
			)
		);

	}

	/**
	 * Determines if the WordPress URL is a local, non-web accessible URL.
	 *
	 * @since   4.1.9
	 *
	 * @return  bool    Locally Hosted Site
	 */
	private function is_local_host() {

		// Get URL of site and its information.
		$url = wp_parse_url( get_bloginfo( 'url' ) );

		// Iterate through local host addresses to check if they exist
		// in part of the site's URL host.
		foreach ( $this->get_local_hosts() as $local_host ) {
			if ( strpos( $url['host'], $local_host ) !== false ) {
				return true;
			}
		}

		// If here, we're not on a local host.
		return false;

	}

	/**
	 * Returns an array of domains and IP addresses that are non-web accessible
	 *
	 * @since   4.1.9
	 *
	 * @return  array   Non-web accessible Domains and IP addresses
	 */
	private function get_local_hosts() {

		// If domain is 127.0.0.1, localhost or .dev, don't count it towards the domain limit
		// The user has a valid license key if they're here, so that's enough.
		// See: https://www.sqa.org.uk/e-learning/WebTech01CD/page_12.htm.
		$local_hosts = array(
			'localhost',
			'127.0.0.1',
			'10.0.',
			'192.168.',
			'.dev',
			'.local',
			'.localhost',
			'.test',
		);

		// Add 172.16.0.* to 172.16.31.*.
		for ( $i = 0; $i <= 31; $i++ ) {
			$local_hosts[] = '172.16.' . $i . '.';
		}

		return $local_hosts;

	}

}
