<?php
/**
 * CDN class: CDN
 *
 * @package Smush\Core\Modules
 * @version 3.0
 */

namespace Smush\Core\Modules;

use stdClass;
use WPMUDEV_Dashboard;
use WP_Error;
use WP_Smush;

if ( ! defined( 'WPINC' ) ) {
	die;
}

/**
 * Class CDN
 */
class CDN extends Abstract_Module {

	/**
	 * Smush CDN base url.
	 *
	 * @var null|string
	 */
	private $cdn_base = null;

	/**
	 * Flag to check if CDN is active.
	 *
	 * @var bool
	 */
	private $cdn_active = false;

	/**
	 * CDN status.
	 *
	 * @var stdClass $status
	 */
	private $status;

	/**
	 * Supported file extensions.
	 *
	 * @var array $supported_extensions
	 */
	private $supported_extensions = array(
		'gif',
		'jpg',
		'jpeg',
		'png',
	);

	/**
	 * Page parser.
	 *
	 * @since 3.2.2
	 * @var Helpers\Parser $parser
	 */
	protected $parser;

	/**
	 * CDN constructor.
	 *
	 * @since 3.2.2
	 * @param Helpers\Parser $parser  Page parser instance.
	 */
	public function __construct( Helpers\Parser $parser ) {
		$this->parser = $parser;
		parent::__construct();
	}

	/**
	 * CDN constructor.
	 */
	public function init() {
		/**
		 * Settings.
		 */
		// Filters the setting variable to add module setting title and description.
		add_filter( 'wp_smush_settings', array( $this, 'register' ) );

		// Add settings descriptions to the meta box.
		add_action( 'smush_setting_column_right_inside', array( $this, 'settings_desc' ), 10, 2 );

		// Cron task to update CDN stats.
		add_action( 'smush_update_cdn_stats', array( $this, 'update_stats' ) );

		// Set auto resize flag.
		$this->init_flags();

		// Add stats to stats box.
		add_action( 'stats_ui_after_resize_savings', array( $this, 'cdn_stats_ui' ), 20 );

		/**
		 * Main functionality.
		 */
		if ( ! $this->settings->get( 'cdn' ) || ! $this->cdn_active ) {
			return;
		}

		// Set Smush API config.
		add_action( 'init', array( $this, 'set_cdn_url' ) );

		// Only do stuff on the frontend.
		if ( is_admin() ) {
			// Verify the cron task to update stats is configured.
			$this->schedule_cron();
			return;
		}

		$this->init_parser();

		// Add cdn url to dns prefetch.
		add_filter( 'wp_resource_hints', array( $this, 'dns_prefetch' ), 99, 2 );

		// Update responsive image srcset and sizes if required.
		add_filter( 'wp_calculate_image_srcset', array( $this, 'update_image_srcset' ), 99, 5 );
		add_filter( 'wp_calculate_image_sizes', array( $this, 'update_image_sizes' ), 1, 2 );

		// Add resizing arguments to image src.
		add_filter( 'smush_image_cdn_args', array( $this, 'update_cdn_image_src_args' ), 99, 3 );

		// Add REST API integration.
		add_filter( 'rest_pre_echo_response', array( $this, 'filter_rest_api_response' ) );
	}

	/**************************************
	 *
	 * PUBLIC METHODS SETTINGS & UI
	 */

	/**
	 * Get CDN status.
	 *
	 * @since 3.0
	 */
	public function get_status() {
		return $this->cdn_active;
	}

	/**
	 * Get the CDN status.
	 *
	 * @return string  Possible return values: disabled/enabled, activating, overcap/upgrade.
	 *
	 * @since 3.2.1
	 */
	public function status() {
		if ( ! $this->cdn_active || ! $this->settings->get( 'cdn' ) ) {
			return 'disabled';
		}

		$cdn = $this->settings->get_setting( WP_SMUSH_PREFIX . 'cdn_status' );

		if ( ! $cdn ) {
			return 'disabled';
		}

		if ( isset( $cdn->cdn_enabling ) && $cdn->cdn_enabling ) {
			return 'activating';
		}

		$plan      = isset( $cdn->bandwidth_plan ) ? $cdn->bandwidth_plan : 10;
		$bandwidth = isset( $cdn->bandwidth ) ? $cdn->bandwidth : 0;

		$percentage = round( 100 * $bandwidth / 1024 / 1024 / 1024 / $plan );

		if ( $percentage > 100 || 100 === (int) $percentage ) {
			return 'overcap';
		} elseif ( 90 <= (int) $percentage ) {
			return 'upgrade';
		}

		return 'enabled';
	}

	/**
	 * Add settings to settings array.
	 *
	 * @since 3.0
	 *
	 * @param array $settings  Current settings array.
	 *
	 * @return array
	 */
	public function register( $settings ) {
		return array_merge(
			$settings,
			array(
				'background_images' => array(
					'label'       => __( 'Serve background images from the CDN', 'wp-smushit' ),
					'short_label' => __( 'Background Images', 'wp-smushit' ),
					'desc'        => __( 'Where possible we will serve background images declared with CSS directly from the CDN.', 'wp-smushit' ),
				),
				'auto_resize'       => array(
					'label'       => __( 'Enable automatic resizing of my images', 'wp-smushit' ),
					'short_label' => __( 'Automatic Resizing', 'wp-smushit' ),
					'desc'        => __( 'If your images don’t match their containers, we’ll automatically serve a correctly sized image.', 'wp-smushit' ),
				),
				'webp'              => array(
					'label'       => __( 'Enable WebP conversion', 'wp-smushit' ),
					'short_label' => __( 'WebP Conversion', 'wp-smushit' ),
					'desc'        => __( 'Smush can automatically convert and serve your images as WebP to compatible browsers.', 'wp-smushit' ),
				),
				'rest_api_support'  => array(
					'label'       => __( 'Enable REST API support', 'wp-smushit' ),
					'short_label' => __( 'REST API', 'wp-smushit' ),
					'desc'        => __( 'Smush can automatically replace image URLs when fetched via REST API endpoints.', 'wp-smushit' ),
				),
			)
		);
	}

	/**
	 * Show additional descriptions for settings.
	 *
	 * @since 3.0
	 *
	 * @param string $setting_key Setting key.
	 */
	public function settings_desc( $setting_key = '' ) {
		if ( empty( $setting_key ) || ! in_array( $setting_key, $this->settings->get_cdn_fields(), true ) ) {
			return;
		}
		?>
		<span class="sui-description sui-toggle-description" id="<?php echo esc_attr( WP_SMUSH_PREFIX . $setting_key . '-desc' ); ?>">
			<?php
			switch ( $setting_key ) {
				case 'webp':
					esc_html_e(
						'Note: We’ll detect and serve WebP images to browsers that will accept them by checking
						Accept Headers, and gracefully fall back to normal PNGs or JPEGs for non-compatible browsers.',
						'wp-smushit'
					);
					break;
				case 'auto_resize':
					esc_html_e( 'Having trouble with Google PageSpeeds ‘properly size images’ suggestion? This feature will fix this without any coding needed! Note: No resizing is done on your actual images, only what is served from the CDN - so your original images will remain untouched.', 'wp-smushit' );
					break;
				case 'background_images':
					printf(
						/* translators: %1$s - link, %2$s - closing link tag */
						esc_html__( 'Note: For this feature to work your theme’s background images must be declared correctly using the default %1$swp_attachment%2$s functions.', 'wp-smushit' ),
						'<a href="https://developer.wordpress.org/reference/functions/wp_get_attachment_image/" target="_blank">',
						'</a>'
					);
					echo '<br>';
					printf(
						/* translators: %1$s - link, %2$s - closing link tag */
						esc_html__( 'For any non-media library uploads, you can still use the %1$sDirectory Smush%2$s feature to compress them, they just won’t be served from the CDN.', 'wp-smushit' ),
						'<a href="' . esc_url( network_admin_url( 'admin.php?page=smush&view=directory' ) ) . '">',
						'</a>'
					);
					break;
				case 'rest_api_support':
					printf(
						/* translators: %1$s - link, %2$s - closing link tag */
						esc_html__( 'Note: Smush will use the %1$srest_pre_echo_response%2$s hook to filter images in REST API responses.', 'wp-smushit' ),
						'<a href="https://developer.wordpress.org/reference/hooks/rest_pre_echo_response/" target="_blank">',
						'</a>'
					);
					break;
				default:
					break;
			}
			?>
		</span>
		<?php
	}

	/**
	 * Add CDN stats to stats meta box.
	 *
	 * @since 3.0
	 */
	public function cdn_stats_ui() {
		$status = $this->status();

		if ( 'disabled' === $status ) {
			return;
		}
		?>
		<li class="smush-cdn-stats">
			<span class="sui-list-label"><?php esc_html_e( 'CDN', 'wp-smushit' ); ?></span>
			<span class="wp-smush-stats sui-list-detail">
				<i class="sui-icon-loader sui-loading sui-hidden" aria-hidden="true" title="<?php esc_attr_e( 'Updating Stats', 'wp-smushit' ); ?>"></i>
				<?php if ( 'overcap' === $status ) : ?>
					<span class="sui-tooltip sui-tooltip-top-right sui-tooltip-constrained" data-tooltip="<?php esc_attr_e( "You've gone through your CDN bandwidth limit, so we’ve stopped serving your images via the CDN. Contact your administrator to upgrade your Smush CDN plan to reactivate this service", 'wp-smushit' ); ?>">
						<i class="sui-icon-warning-alert sui-error sui-md" aria-hidden="true"></i>
					</span>
					<span><?php esc_html_e( 'Overcap', 'wp-smushit' ); ?></span>
				<?php elseif ( 'upgrade' === $status ) : ?>
					<span class="sui-tooltip sui-tooltip-top-right sui-tooltip-constrained" data-tooltip="<?php esc_attr_e( "You're almost through your CDN bandwidth limit. Please contact your administrator to upgrade your Smush CDN plan to ensure you don't lose this service", 'wp-smushit' ); ?>">
						<i class="sui-icon-warning-alert sui-warning sui-md" aria-hidden="true"></i>
					</span>
					<span><?php esc_html_e( 'Needs upgrade', 'wp-smushit' ); ?></span>
				<?php elseif ( 'activating' === $status ) : ?>
					<i class="sui-icon-check-tick sui-info sui-md" aria-hidden="true"></i>
					<span><?php esc_html_e( 'Activating', 'wp-smushit' ); ?></span>
				<?php else : ?>
					<span class="sui-tooltip sui-tooltip-top-right sui-tooltip-constrained" data-tooltip="<?php esc_attr_e( 'Your media is currently being served from the WPMU DEV CDN. Bulk and Directory smush features are treated separately and will continue to run independently.', 'wp-smushit' ); ?>">
						<i class="sui-icon-check-tick sui-info sui-md" aria-hidden="true"></i>
					</span>
					<span><?php esc_html_e( 'Active', 'wp-smushit' ); ?></span>
				<?php endif; ?>
			</span>
		</li>
		<?php
	}

	/**
	 * Initialize required flags.
	 */
	public function init_flags() {
		// All these are members only feature.
		if ( ! WP_Smush::is_pro() ) {
			return;
		}

		// CDN will not work if there is no dashboard plugin installed.
		if ( ! file_exists( WP_PLUGIN_DIR . '/wpmudev-updates/update-notifications.php' ) ) {
			return;
		}

		// CDN will not work if site is not registered with the dashboard.
		if ( class_exists( 'WPMUDEV_Dashboard' ) && ! WPMUDEV_Dashboard::$api->has_key() ) {
			return;
		}

		$this->status = $this->settings->get_setting( WP_SMUSH_PREFIX . 'cdn_status' );

		// CDN is not enabled and not active.
		if ( ! $this->status ) {
			return;
		}

		$this->cdn_active = isset( $this->status->cdn_enabled ) && $this->status->cdn_enabled;
	}

	/**
	 * Set the API base for the member.
	 */
	public function set_cdn_url() {
		$site_id = absint( $this->status->site_id );

		$this->cdn_base = trailingslashit( "https://{$this->status->endpoint_url}/{$site_id}" );
	}

	/**
	 * Add CDN url to header for better speed.
	 *
	 * @since 3.0
	 *
	 * @param array  $urls URLs to print for resource hints.
	 * @param string $relation_type The relation type the URLs are printed.
	 *
	 * @return array
	 */
	public function dns_prefetch( $urls, $relation_type ) {
		// Add only if CDN active.
		if ( 'dns-prefetch' === $relation_type && $this->cdn_active && ! empty( $this->cdn_base ) ) {
			$urls[] = $this->cdn_base;
		}

		return $urls;
	}

	/**
	 * Generate CDN url from given image url.
	 *
	 * @since 3.0
	 *
	 * @param string $src Image url.
	 * @param array  $args Query parameters.
	 *
	 * @return string
	 */
	public function generate_cdn_url( $src, $args = array() ) {
		// Do not continue in case we try this when cdn is disabled.
		if ( ! $this->cdn_active ) {
			return $src;
		}

		/**
		 * Filter hook to alter image src before going through cdn.
		 *
		 * @since 3.4.0
		 * @see smush_image_src_before_cdn filter if you need earlier access with the image element.
		 *
		 * @param string $src  Image src.
		 */
		$src = apply_filters( 'smush_filter_generate_cdn_url', $src );

		// Support for WP installs in subdirectories: remove the site url and leave only the file path.
		$path = str_replace( get_site_url(), '', $src );

		// Parse url to get all parts.
		$url_parts = wp_parse_url( $path );

		// If path not found, do not continue.
		if ( empty( $url_parts['path'] ) ) {
			return $src;
		}

		// Arguments for CDN.
		$pro_args = array(
			'lossy' => $this->settings->get( 'lossy' ) ? 1 : 0,
			'strip' => $this->settings->get( 'strip_exif' ) ? 1 : 0,
			'webp'  => $this->settings->get( 'webp' ) ? 1 : 0,
		);

		$args = wp_parse_args( $pro_args, $args );

		// Replace base url with cdn base.
		$url = $this->cdn_base . ltrim( $url_parts['path'], '/' );

		// Now we need to add our CDN parameters for resizing.
		$url = add_query_arg( $args, $url );

		return $url;
	}

	/**************************************
	 *
	 * PUBLIC METHODS CDN
	 *
	 * @see parse_image()
	 * @see parse_background_image()
	 * @see process_src()
	 * @see update_image_srcset()
	 * @see update_image_sizes()
	 * @see update_cdn_image_src_args()
	 * @see process_cdn_status()
	 * @see update_stats()
	 * @see unschedule_cron()
	 * @see schedule_cron()
	 * @see filter_rest_api_response()
	 */

	/**
	 * Parse image for CDN.
	 *
	 * @since 3.2.2  Moved out to a separate function.
	 * @since 3.5.0  Added $srcset and $type params.
	 *
	 * @param string $src     Image URL.
	 * @param string $image   Image tag (<img>).
	 * @param string $srcset  Image srcset content.
	 * @param string $type    Element type. Accepts: 'img', 'source' or 'iframe'. Default: 'img'.
	 *
	 * @return string
	 */
	public function parse_image( $src, $image, $srcset = '', $type = 'img' ) {
		/**
		 * Filter to skip a single image from cdn.
		 *
		 * @param bool       $skip   Should skip? Default: false.
		 * @param string     $src    Image url.
		 * @param array|bool $image  Image tag or false.
		 */
		if ( apply_filters( 'smush_skip_image_from_cdn', false, $src, $image ) ) {
			return $image;
		}

		$new_image = $image;

		/**
		 * Support for source in picture element.
		 */
		if ( 'source' === $type && $srcset ) {
			$links = Helpers\Parser::get_links_from_content( $srcset );
			if ( ! isset( $links[0] ) || ! is_array( $links[0] ) ) {
				return $new_image;
			}

			foreach ( $links[0] as $link ) {
				$src = $this->is_supported_path( $link );
				if ( ! $src ) {
					continue;
				}

				// Replace the data-envira-srcset of the image with CDN link.
				$src = $this->generate_cdn_url( $src );
				if ( $src ) {
					// Replace the src of the image with CDN link.
					$new_image = str_replace( $link, $src, $new_image );
				}
			}

			// We can exit early, to avoid additional parsing.
			return $new_image;
		}

		// Make sure this image is inside a supported directory. Try to convert to valid path.
		$src = $this->is_supported_path( $src );
		if ( $src ) {
			// Store the original $src to be used later on.
			$original_src = $src;

			$src = $this->process_src( $image, $src, false );

			// Replace the src of the image with CDN link.
			if ( ! empty( $src ) ) {
				$new_image = preg_replace( '#(src=["|\'])' . $original_src . '(["|\'])#i', '\1' . $src . '\2', $new_image, 1 );
			}

			/**
			 * See if srcset is already set.
			 *
			 * The preg_match is required to make sure that srcset is not already defined.
			 * For the majority of images, srcset will be parsed as part of the wp_calculate_image_srcset filter.
			 * But some images, for example, logos in Avada - will add their own srcset. For such images - generate our own.
			 */
			if ( ! preg_match( '/srcset=["\'](.*?smushcdn\.com[^"\']+)["\']/i', $image ) && $this->settings->get( 'auto_resize' ) && ! apply_filters( 'smush_skip_adding_srcset', false ) ) {
				list( $srcset, $sizes ) = $this->generate_srcset( $original_src );

				if ( ! is_null( $srcset ) && false !== $srcset ) {
					// Remove possibly empty srcset attribute.
					Helpers\Parser::remove_attribute( $new_image, 'srcset' );
					Helpers\Parser::add_attribute( $new_image, 'srcset', $srcset );
				}

				if ( ! is_null( $srcset ) && false !== $sizes ) {
					// Remove possibly empty sizes attribute.
					Helpers\Parser::remove_attribute( $new_image, 'sizes' );
					Helpers\Parser::add_attribute( $new_image, 'sizes', $sizes );
				}
			}
		}

		// Support for 3rd party lazy loading plugins.
		$lazy_attributes = array( 'data-src', 'data-lazy-src', 'data-lazyload', 'data-original' );
		foreach ( $lazy_attributes as $attr ) {
			$data_src = Helpers\Parser::get_attribute( $new_image, $attr );
			$data_src = $this->is_supported_path( $data_src );
			if ( $data_src ) {
				$cdn_image = $this->process_src( $image, $data_src );
				Helpers\Parser::remove_attribute( $new_image, $attr );
				Helpers\Parser::add_attribute( $new_image, $attr, $cdn_image );
			}
		}

		/**
		 * Filter hook to alter image tag before replacing the image in content.
		 *
		 * @param string $image  Image tag.
		 */
		return apply_filters( 'smush_cdn_image_tag', $new_image );
	}

	/**
	 * Parse background image for CDN.
	 *
	 * @since 3.2.2
	 *
	 * @param string $src    Image URL.
	 * @param string $image  Image tag (<img>).
	 *
	 * @return string
	 */
	public function parse_background_image( $src, $image ) {
		/**
		 * Filter to skip a single image from cdn.
		 *
		 * @param bool       $skip   Should skip? Default: false.
		 * @param string     $src    Image url.
		 * @param array|bool $image  Image tag or false.
		 */
		if ( apply_filters( 'smush_skip_background_image_from_cdn', false, $src, $image ) ) {
			return $image;
		}

		$new_image = $image;

		// Store the original $src to be used later on.
		$original_src = $src;

		// Make sure this image is inside a supported directory. Try to convert to valid path.
		$src = $this->is_supported_path( $src );
		if ( $src ) {
			$src = $this->process_src( $image, $src );

			// Replace the src of the image with CDN link.
			if ( ! empty( $src ) ) {
				$new_image = str_replace( $original_src, $src, $new_image );
			}
		}

		/**
		 * Filter hook to alter image tag before replacing the background image in content.
		 *
		 * @param string $image  Image tag.
		 */
		return apply_filters( 'smush_cdn_bg_image_tag', $new_image );
	}

	/**
	 * Process src link and convert to CDN link.
	 *
	 * @since 3.2.1
	 *
	 * @param string $image     Image tag.
	 * @param string $src       Image src attribute.
	 * @param bool   $resizing  Add resizing arguments. Defaults to true.
	 *                          We should never add resize arguments to the images from src. But we can and should
	 *                          add them to the srcset and other possible attributes.
	 *
	 * @return string
	 */
	private function process_src( $image, $src, $resizing = true ) {
		$args = array();

		// Don't need to auto resize - return default args.
		if ( $resizing && $this->settings->get( 'auto_resize' ) ) {
			/**
			 * Filter hook to alter image src arguments before going through cdn.
			 *
			 * @param array  $args   Arguments.
			 * @param string $src    Image src.
			 * @param string $image  Image tag.
			 */
			$args = apply_filters( 'smush_image_cdn_args', array(), $image );
		}

		/**
		 * Filter hook to alter image src before going through cdn.
		 *
		 * @param string $src    Image src.
		 * @param string $image  Image tag.
		 */
		$src = apply_filters( 'smush_image_src_before_cdn', $src, $image );

		// Generate cdn url from local url.
		$src = $this->generate_cdn_url( $src, $args );

		/**
		 * Filter hook to alter image src after replacing with CDN base.
		 *
		 * @param string $src    Image src.
		 * @param string $image  Image tag.
		 */
		$src = apply_filters( 'smush_image_src_after_cdn', $src, $image );

		return $src;
	}

	/**
	 * Filters an array of image srcset values, replacing each URL with resized CDN urls.
	 *
	 * Keep the existing srcset sizes if already added by WP, then calculate extra sizes
	 * if required.
	 *
	 * @since 3.0
	 *
	 * @param array  $sources        One or more arrays of source data to include in the 'srcset'.
	 * @param array  $size_array     Array of width and height values in pixels.
	 * @param string $image_src      The 'src' of the image.
	 * @param array  $image_meta     The image metadata as returned by 'wp_get_attachment_metadata()'.
	 * @param int    $attachment_id  Image attachment ID or 0.
	 *
	 * @return array $sources
	 */
	public function update_image_srcset( $sources, $size_array, $image_src, $image_meta, $attachment_id = 0 ) {
		if ( ! is_array( $sources ) ) {
			return $sources;
		}

		$main_image_url = false;

		// Try to get image URL from attachment ID.
		if ( empty( $attachment_id ) ) {
			$url = $main_image_url = wp_get_attachment_url( $attachment_id );
		}

		foreach ( $sources as $i => $source ) {
			if ( ! $this->is_valid_url( $source['url'] ) ) {
				continue;
			}

			if ( apply_filters( 'smush_cdn_skip_image', false, $source['url'], $source ) ) {
				continue;
			}

			list( $width, $height ) = $this->get_size_from_file_name( $source['url'] );

			// The file already has a resized version as a thumbnail.
			if ( 'w' === $source['descriptor'] && $width === (int) $source['value'] ) {
				$sources[ $i ]['url'] = $this->generate_cdn_url( $source['url'] );
				continue;
			}

			// If don't have attachment id, get original image by removing dimensions from url.
			if ( empty( $url ) ) {
				$url = $this->get_url_without_dimensions( $source['url'] );
			}

			$args = array();
			// If we got size from url, add them.
			if ( ! empty( $width ) && ! empty( $height ) ) {
				// Set size arg.
				$args = array(
					'size' => "{$width}x{$height}",
				);
			}

			// Replace with CDN url.
			$sources[ $i ]['url'] = $this->generate_cdn_url( $url, $args );
		}

		// Set additional sizes if required.
		if ( $this->settings->get( 'auto_resize' ) ) {
			$sources = $this->set_additional_srcset( $sources, $size_array, $main_image_url, $image_meta, $image_src );

			// Make it look good.
			ksort( $sources );
		}

		return $sources;
	}

	/**
	 * Update image sizes for responsive size.
	 *
	 * @since 3.0
	 *
	 * @param string $sizes A source size value for use in a 'sizes' attribute.
	 * @param array  $size  Requested size.
	 *
	 * @return string
	 */
	public function update_image_sizes( $sizes, $size ) {
		// Get maximum content width.
		$content_width = $this->max_content_width();

		if ( is_array( $size ) && $size[0] < $content_width ) {
			return $sizes;
		}

		return sprintf( '(max-width: %1$dpx) 100vw, %1$dpx', $content_width );
	}

	/**
	 * Add resize arguments to content image src.
	 *
	 * @since 3.0
	 *
	 * @param array  $args  Current arguments.
	 * @param object $image Image tag object from DOM.
	 *
	 * @return array $args
	 */
	public function update_cdn_image_src_args( $args, $image ) {
		// Get registered image sizes.
		$image_sizes = WP_Smush::get_instance()->core()->image_dimensions();

		// Find the width and height attributes.
		$width  = false;
		$height = false;

		// Try to get the width and height from img tag.
		if ( preg_match( '/width=["|\']?(\b[[:digit:]]+(?!%)\b)["|\']?/i', $image, $width_string ) ) {
			$width = $width_string[1];
		}

		if ( preg_match( '/height=["|\']?(\b[[:digit:]]+(?!%)\b)["|\']?/i', $image, $height_string ) ) {
			$height = $height_string[1];
		}

		$size = array();

		// Detect WP registered image size from HTML class.
		if ( preg_match( '/size-([^"\'\s]+)[^"\']*["|\']?/i', $image, $size ) ) {
			$size = array_pop( $size );

			if ( ! array_key_exists( $size, $image_sizes ) ) {
				return $args;
			}

			// This is probably a correctly sized thumbnail - no need to resize.
			if ( (int) $width === $image_sizes[ $size ]['width'] || (int) $height === $image_sizes[ $size ]['height'] ) {
				return $args;
			}

			// If this size exists in registered sizes, add argument.
			if ( 'full' !== $size ) {
				$args['size'] = (int) $image_sizes[ $size ]['width'] . 'x' . (int) $image_sizes[ $size ]['height'];
			}
		} else {
			// It's not a registered thumbnail size.
			if ( $width && $height ) {
				$args['size'] = (int) $width . 'x' . (int) $height;
			}
		}

		return $args;
	}

	/**
	 * Process CDN status.
	 *
	 * @since 3.0
	 * @since 3.1  Moved from Ajax class.
	 *
	 * @param array|WP_Error $status  Status in JSON format.
	 *
	 * @return mixed
	 */
	public function process_cdn_status( $status ) {
		if ( is_wp_error( $status ) ) {
			wp_send_json_error(
				array(
					'message' => $status->get_error_message(),
				)
			);
		}

		$status = json_decode( $status['body'] );

		// Too many requests.
		if ( is_null( $status ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'Too many requests, please try again in a moment.', 'wp-smushit' ),
				)
			);
		}

		// Some other error from API.
		if ( ! $status->success ) {
			wp_send_json_error(
				array(
					'message' => $status->data->message,
				),
				$status->data->error_code
			);
		}

		return $status->data;
	}

	/**
	 * Update CDN stats (daily) cron task or via the get_cdn_stats ajax request.
	 *
	 * @since 3.1.0
	 */
	public function update_stats() {
		$status = $this->settings->get_setting( WP_SMUSH_PREFIX . 'cdn_status' );

		$smush = WP_Smush::get_instance();

		if ( isset( $status->cdn_enabling ) && $status->cdn_enabling ) {
			$status = $this->process_cdn_status( $smush->api()->enable() );
			$this->settings->set_setting( WP_SMUSH_PREFIX . 'cdn_status', $status );
		}

		if ( ! wp_doing_cron() ) {
			// At this point we already know that $status->data is valid.
			wp_send_json_success( $status );
		}
	}

	/**
	 * Disable CDN stats update cron task.
	 *
	 * @since 3.1.0
	 */
	public function unschedule_cron() {
		$timestamp = wp_next_scheduled( 'smush_update_cdn_stats' );
		wp_unschedule_event( $timestamp, 'smush_update_cdn_stats' );
	}

	/**
	 * Set cron task to update CDN stats daily.
	 *
	 * @since 3.1.0
	 */
	public function schedule_cron() {
		if ( ! wp_next_scheduled( 'smush_update_cdn_stats' ) ) {
			// Schedule first run for next day, as we've already checked just now.
			wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'smush_update_cdn_stats' );
		}
	}

	/**
	 * Filters the API response.
	 *
	 * Allows modification of the response data after inserting
	 * embedded data (if any) and before echoing the response data.
	 *
	 * @since 3.6.0
	 *
	 * @param array $response  Response data to send to the client.
	 *
	 * @return array
	 */
	public function filter_rest_api_response( $response ) {
		if ( ! $this->settings->get( 'rest_api_support' ) ) {
			return $response;
		}

		if ( ! isset( $response['content']['rendered'] ) ) {
			return $response;
		}

		$images = Helpers\Parser::get_links_from_content( $response['content']['rendered'] );

		if ( ! isset( $images[0] ) || empty( $images[0] ) ) {
			return $response;
		}

		foreach ( $images[0] as $key => $image ) {
			$image = $this->is_supported_path( $image );
			if ( ! $image ) {
				continue;
			}

			// Replace the data-envira-srcset of the image with CDN link.
			$image = $this->generate_cdn_url( $image );
			if ( $image ) {
				// Replace the src of the image with CDN link.
				$response['content']['rendered'] = str_replace( $images[0][ $key ], $image, $response['content']['rendered'] );
			}
		}

		return $response;
	}

	/**************************************
	 *
	 * PRIVATE METHODS
	 *
	 * Functions that are used by the public methods of this CDN class.
	 *
	 * @since 3.0.0:
	 *
	 * @see is_valid_url()
	 * @see get_size_from_file_name()
	 * @see get_url_without_dimensions()
	 * @see max_content_width()
	 * @see set_additional_srcset()
	 * @see generate_srcset()
	 * @see maybe_generate_srcset()
	 * @see is_supported_path()
	 */

	/**
	 * Check if we can use the image URL in CDN.
	 *
	 * @since 3.0
	 *
	 * @param string $url  Image URL.
	 *
	 * @return bool
	 */
	private function is_valid_url( $url ) {
		$parsed_url = wp_parse_url( $url );

		if ( ! $parsed_url ) {
			return false;
		}

		// No host or path found.
		if ( ! isset( $parsed_url['host'] ) || ! isset( $parsed_url['path'] ) ) {
			return false;
		}

		// If not supported extension - return false.
		if ( ! in_array( strtolower( pathinfo( $parsed_url['path'], PATHINFO_EXTENSION ) ), $this->supported_extensions, true ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Try to determine height and width from strings WP appends to resized image filenames.
	 *
	 * @since 3.0
	 *
	 * @param string $src The image URL.
	 *
	 * @return array An array consisting of width and height.
	 */
	private function get_size_from_file_name( $src ) {
		$size = array();

		if ( preg_match( '/(\d+)x(\d+)\.(?:' . implode( '|', $this->supported_extensions ) . ')$/i', $src, $size ) ) {
			// Get size and width.
			$width  = (int) $size[1];
			$height = (int) $size[2];

			// Handle retina images.
			if ( strpos( $src, '@2x' ) ) {
				$width  = 2 * $width;
				$height = 2 * $height;
			}

			// Return width and height as array.
			if ( $width && $height ) {
				return array( $width, $height );
			}
		}

		return array( false, false );
	}

	/**
	 * Get full size image url from resized one.
	 *
	 * @since 3.0
	 *
	 * @param string $src Image URL.
	 *
	 * @return string
	 */
	private function get_url_without_dimensions( $src ) {
		if ( ! preg_match( '/(-\d+x\d+)\.(' . implode( '|', $this->supported_extensions ) . ')(?:\?.+)?$/i', $src, $src_parts ) ) {
			return $src;
		}

		// Remove WP's resize string to get the original image.
		$original_src = str_replace( $src_parts[1], '', $src );

		// Upload directory.
		$upload_dir = wp_get_upload_dir();

		// Extracts the file path to the image minus the base url.
		$file_path = substr( $original_src, strlen( $upload_dir['baseurl'] ) );

		// Continue only if the file exists.
		if ( file_exists( $upload_dir['basedir'] . $file_path ) ) {
			return $original_src;
		}

		// Revert to source if file does not exist.
		return $src;
	}

	/**
	 * Get $content_width global var value.
	 *
	 * @since 3.0
	 *
	 * @return bool|string
	 */
	private function max_content_width() {
		// Get global content width (if content width is empty, set 1900).
		$content_width = isset( $GLOBALS['content_width'] ) ? (int) $GLOBALS['content_width'] : 1920;

		// Check to see if we are resizing the images (can not go over that value).
		$resize_sizes = $this->settings->get_setting( WP_SMUSH_PREFIX . 'resize_sizes' );

		if ( isset( $resize_sizes['width'] ) && $resize_sizes['width'] < $content_width ) {
			return $resize_sizes['width'];
		}

		return $content_width;
	}

	/**
	 * Filters an array of image srcset values, and add additional values.
	 *
	 * @since 3.0
	 *
	 * @param array  $sources    An array of image urls and widths.
	 * @param array  $size_array Array of width and height values in pixels.
	 * @param string $url        Image URL.
	 * @param array  $image_meta The image metadata.
	 * @param string $image_src  The src of the image.
	 *
	 * @return array $sources
	 */
	private function set_additional_srcset( $sources, $size_array, $url, $image_meta, $image_src = '' ) {
		$content_width = $this->max_content_width();

		// If url is empty, try to get from src.
		if ( empty( $url ) ) {
			$url = $this->get_url_without_dimensions( $image_src );
		}

		// We need to add additional dimensions.
		$full_width     = $image_meta['width'];
		$full_height    = $image_meta['height'];
		$current_width  = $size_array[0];
		$current_height = $size_array[1];
		// Get width and height calculated by WP.
		list( $constrained_width, $constrained_height ) = wp_constrain_dimensions( $full_width, $full_height, $current_width, $current_height );

		// Calculate base width.
		// If $constrained_width sizes are smaller than current size, set maximum content width.
		if ( abs( $constrained_width - $current_width ) <= 1 && abs( $constrained_height - $current_height ) <= 1 ) {
			$base_width = $content_width;
		} else {
			$base_width = $current_width;
		}

		$current_widths = array_keys( $sources );
		$new_sources    = array();

		/**
		 * Filter to add/update/bypass additional srcsets.
		 *
		 * If empty value or false is retured, additional srcset
		 * will not be generated.
		 *
		 * @param array|bool $additional_multipliers Additional multipliers.
		 */
		$additional_multipliers = apply_filters(
			'smush_srcset_additional_multipliers',
			array(
				0.2,
				0.4,
				0.6,
				0.8,
				1,
				2,
				3,
			)
		);

		// Continue only if additional multipliers found or not skipped.
		// Filter already documented in class-cdn.php.
		if ( apply_filters( 'smush_skip_image_from_cdn', false, $url, false ) || empty( $additional_multipliers ) ) {
			return $sources;
		}

		// Loop through each multipliers and generate image.
		foreach ( $additional_multipliers as $multiplier ) {
			// New width by multiplying with original size.
			$new_width = intval( $base_width * $multiplier );

			// In most cases - going over the current width is not recommended and probably not what the user is expecting.
			if ( $new_width > $current_width ) {
				continue;
			}

			// If a nearly sized image already exist, skip.
			foreach ( $current_widths as $_width ) {
				// If +- 50 pixel difference - skip.
				if ( abs( $_width - $new_width ) < 50 || ( $new_width > $full_width ) ) {
					continue 2;
				}
			}

			// We need the width as well...
			$dimensions = wp_constrain_dimensions( $current_width, $current_height, $new_width );

			// Arguments for cdn url.
			$args = array(
				'size' => "{$new_width}x{$dimensions[1]}",
			);

			// Add new srcset item.
			$new_sources[ $new_width ] = array(
				'url'        => $this->generate_cdn_url( $url, $args ),
				'descriptor' => 'w',
				'value'      => $new_width,
			);
		}

		// Assign new srcset items to existing ones.
		if ( ! empty( $new_sources ) ) {
			// Loop through each items and replace/add.
			foreach ( $new_sources as $_width_key => $_width_values ) {
				$sources[ $_width_key ] = $_width_values;
			}
		}

		return $sources;
	}

	/**
	 * Try to generate the srcset for the image.
	 *
	 * @since 3.0
	 *
	 * @param string $src  Image source.
	 *
	 * @return array|bool
	 */
	private function generate_srcset( $src ) {
		/**
		 * Try to get the attachment URL.
		 *
		 * TODO: attachment_url_to_postid() can be resource intensive and cause 100% CPU spikes.
		 * @see https://core.trac.wordpress.org/ticket/41281
		 */
		$attachment_id = attachment_url_to_postid( $src );

		// Try to get width and height from image.
		if ( $attachment_id ) {
			list( $src, $width, $height ) = wp_get_attachment_image_src( $attachment_id, 'full' );

			// Revolution slider fix: images will always return 0 height and 0 width.
			if ( 0 === $width && 0 === $height ) {
				// Try to get the dimensions directly from the file.
				list( $width, $height ) = $this->get_image_size( $src );
			}

			$image_meta = wp_get_attachment_metadata( $attachment_id );
		} else {
			// Try to get the dimensions directly from the file.
			list( $width, $height ) = $this->get_image_size( $src );

			// This is an image placeholder - do not generate srcset.
			if ( $width === $height && 1 === $width ) {
				return false;
			}

			$image_meta = array(
				'width'  => $width,
				'height' => $height,
			);
		}

		$size_array = array( absint( $width ), absint( $height ) );
		$srcset     = wp_calculate_image_srcset( $size_array, $src, $image_meta, $attachment_id );

		/**
		 * In some rare cases, the wp_calculate_image_srcset() will not generate any srcset, because there are
		 * not image sizes defined. If that is the case, try to revert to our custom maybe_generate_srcset() to
		 * generate the srcset string.
		 *
		 * Also srcset will not be generated for images that are not part of the media library (no $attachment_id).
		 */
		if ( ! $srcset ) {
			$srcset = $this->maybe_generate_srcset( $width, $height, $src, $image_meta );
		}

		$sizes = wp_calculate_image_sizes( $size_array, $src, $image_meta, $attachment_id );

		return array( $srcset, $sizes );
	}

	/**
	 * Try to generate srcset.
	 *
	 * @since 3.0
	 *
	 * @param int    $width   Attachment width.
	 * @param int    $height  Attachment height.
	 * @param string $src     Image source.
	 * @param array  $meta    Image meta.
	 *
	 * @return string
	 */
	private function maybe_generate_srcset( $width, $height, $src, $meta ) {
		$sources[ $width ] = array(
			'url'        => $this->generate_cdn_url( $src ),
			'descriptor' => 'w',
			'value'      => $width,
		);

		$sources = $this->set_additional_srcset(
			$sources,
			array( absint( $width ), absint( $height ) ),
			$src,
			$meta
		);

		$srcset = '';
		foreach ( $sources as $source ) {
			$srcset .= str_replace( ' ', '%20', $source['url'] ) . ' ' . $source['value'] . $source['descriptor'] . ', ';
		}

		return $srcset;
	}

	/**
	 * Check if the image path is supported by the CDN.
	 *
	 * @since 3.0
	 * @since 3.3.0 Changed access to public.
	 *
	 * @param string $src  Image path.
	 *
	 * @return bool|string
	 */
	public function is_supported_path( $src ) {
		// Allow only these extensions in CDN.
		$ext = strtolower( pathinfo( $src, PATHINFO_EXTENSION ) );
		if ( ! in_array( $ext, $this->supported_extensions, true ) ) {
			return false;
		}

		$url_parts = wp_parse_url( $src );

		// Unsupported scheme.
		if ( isset( $url_parts['scheme'] ) && 'http' !== $url_parts['scheme'] && 'https' !== $url_parts['scheme'] ) {
			return false;
		}

		if ( ! isset( $url_parts['scheme'] ) && 0 === strpos( $src, '//' ) ) {
			$src = is_ssl() ? 'https:' . $src : 'http:' . $src;
		}

		// This is a relative path, try to get the URL.
		if ( ! isset( $url_parts['host'] ) && ! isset( $url_parts['scheme'] ) ) {
			$src = site_url( $src );
		}

		$mapped_domain = $this->check_mapped_domain();

		/**
		 * There are chances for a custom uploads directory using UPLOADS constant.
		 *
		 * But some security plugins (for example, WP Hide & Security Enhance) will allow replacing paths via Nginx/Apache
		 * rules. So for this reason, we don't want the path to be replaced everywhere with the custom UPLOADS constant,
		 * we just want to let the user redefine it here, in the CDN.
		 *
		 * @since 3.4.0
		 *
		 * @param array $uploads {
		 *     Array of information about the upload directory.
		 *
		 *     @type string       $path    Base directory and subdirectory or full path to upload directory.
		 *     @type string       $url     Base URL and subdirectory or absolute URL to upload directory.
		 *     @type string       $subdir  Subdirectory if uploads use year/month folders option is on.
		 *     @type string       $basedir Path without subdir.
		 *     @type string       $baseurl URL path without subdir.
		 *     @type string|false $error   False or error message.
		 * }
		 *
		 * Usage (replace /wp-content/uploads/ with /media/ directory):
		 *
		 * add_filter(
		 *     'smush_cdn_custom_uploads_dir',
		 *     function( $uploads ) {
		 *         $uploads['baseurl'] = 'https://example.com/media';
		 *         return $uploads;
		 *     }
		 * );
		 */
		$uploads = apply_filters( 'smush_cdn_custom_uploads_dir', wp_get_upload_dir() );
		// Check if the src is within custom uploads directory.
		$uploads = isset( $uploads['baseurl'] ) ? false !== strpos( $src, $uploads['baseurl'] ) : true;

		if ( ( false === strpos( $src, content_url() ) && ! $uploads ) || ( is_multisite() && $mapped_domain && false === strpos( $src, $mapped_domain ) ) ) {
			return false;
		}

		return $src;
	}

	/**
	 * Support for domain mapping plugin.
	 *
	 * @since 3.1.1
	 */
	private function check_mapped_domain() {
		if ( ! is_multisite() ) {
			return false;
		}

		if ( ! defined( 'DOMAINMAP_BASEFILE' ) ) {
			return false;
		}

		$domain = wp_cache_get( 'smush_mapped_site_domain', 'smush' );

		if ( ! $domain ) {
			global $wpdb;

			$domain = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT domain FROM {$wpdb->base_prefix}domain_mapping WHERE blog_id = %d ORDER BY id LIMIT 1",
					get_current_blog_id()
				)
			); // Db call ok.

			if ( null !== $domain ) {
				wp_cache_add( 'smush_mapped_site_domain', $domain, 'smush' );
			}
		}

		return $domain;
	}

	/**
	 * Init the page parser.
	 */
	private function init_parser() {
		$background_images = $this->settings->get( 'background_images' );

		if ( $background_images ) {
			$this->parser->enable( 'background_images' );
		}

		$this->parser->enable( 'cdn' );
	}

	/**
	 * Try to get the image dimensions from a local file.
	 *
	 * @since 3.4.0
	 * @param string $url  Image URL.
	 *
	 * @return array|false
	 */
	private function get_image_size( $url ) {
		$path = wp_make_link_relative( $url );
		$path = wp_normalize_path( ABSPATH . $path );
		return getimagesize( $path );
	}

}