<?php
/**
 * Class for Background processing of Media Library images.
 *
 * @link https://ewww.io
 * @package EWWW_Image_Optimizer
 */

namespace EWWW;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Processes media uploads in background/async mode.
 *
 * Uses a db queue system to track uploads to be optimized, handling them one at a time.
 *
 * @see EWWW\Background_Process
 */
class Background_Process_Media extends Background_Process {

	/**
	 * The action name used to trigger this class extension.
	 *
	 * @access protected
	 * @var string $action
	 */
	protected $action = 'ewwwio_media_optimize';

	/**
	 * The queue name for this class extension.
	 *
	 * @access protected
	 * @var string $action
	 */
	protected $active_queue = 'media-async';

	/**
	 * Batch size limit.
	 *
	 * @var int
	 * @access protected
	 */
	protected $limit = 500;

	/**
	 * Metadata for checking attachments.
	 *
	 * @var array
	 * @access protected
	 */
	protected $attachments_meta = array();

	/**
	 * Get batch
	 *
	 * @return array Return the first batch from the queue
	 */
	protected function get_batch() {
		\ewwwio_debug_message( '<b>' . __METHOD__ . '()</b>' );

		$batch = parent::get_batch();
		if ( empty( $batch ) ) {
			return array();
		}
		$attachment_ids = array();
		foreach ( $batch as $item ) {
			if ( ! empty( $item['attachment_id'] ) ) {
				$attachment_ids[] = $item['attachment_id'];
			}
		}

		// This retrieves the mime type, _wp_attached_file, _wp_attachment_metadata, tinypng and wpml status from the postmeta table.
		// Done in bulk, because it is way faster.
		if ( ! empty( $attachment_ids ) ) {
			$this->attachments_meta = ewww_image_optimizer_fetch_metadata_batch( $attachment_ids );

		}
		return $batch;
	}

	/**
	 * Handle
	 *
	 * @global string|array $optimized_list A list of all images that have been optimized, or a string
	 *                                      indicating why that is not a good idea.
	 *
	 * Wrapper around parent::handle() to verify that background processing isn't paused.
	 */
	protected function handle() {
		if ( \get_option( 'ewww_image_optimizer_pause_queues' ) ) {
			\ewwwio_debug_message( 'all queues paused' );
			return;
		}

		global $optimized_list;
		if ( empty( $optimized_list ) && $this->count_queue() > 100 ) {
			\ewww_image_optimizer_optimized_list();
		} else {
			\ewwwio_debug_message( 'less than 100 attachments, not running _optimized_list()' );
			$optimized_list = 'small_scan';
		}

		parent::handle();
	}

	/**
	 * Runs task for an item from the Media Library queue.
	 *
	 * Makes sure an image upload has finished processing and has been stored in the database.
	 * Then runs the usual media optimization routine on the specified item.
	 *
	 * @access protected
	 *
	 * @param array $item The id of the attachment, how many attempts have been made to process
	 *                    the item, the type of attachment, and whether it is a new upload.
	 * @return bool|array If the item is not complete, return it. False indicates completion.
	 */
	protected function task( $item ) {
		session_write_close();
		\ewwwio_debug_message( '<b>' . __METHOD__ . '()</b>' );
		ewwwio()->defer = false;
		$attachment_id  = $item['attachment_id'];
		if ( empty( $item['attempts'] ) && ! empty( $item['new'] ) ) {
			ewwwio_debug_message( 'first attempt on new upload, going to sleep for a second' );
			$item['attempts'] = 0;
			sleep( 1 ); // On the first attempt, hold off and wait for the db to catch up.
		}
		if ( ! empty( $this->attachments_meta[ $attachment_id ]['type'] ) ) {
			$type = $this->attachments_meta[ $attachment_id ]['type'];
		} else {
			$type = get_post_mime_type( $attachment_id );
		}
		if ( empty( $type ) ) {
			ewwwio_debug_message( "mime is missing, requeueing (previous attempts: {$item['attempts']})" );
			sleep( 4 );
			return $item;
		}

		ewwwio_debug_message( "background processing $attachment_id, type: " . $type );
		$supported_types = ewwwio()->get_supported_types();

		if ( in_array( $type, $supported_types, true ) && $item['new'] && class_exists( 'wpCloud\StatelessMedia\EWWW' ) ) {
			$meta = wp_get_attachment_metadata( $attachment_id );
		} elseif ( ! empty( $this->attachments_meta[ $attachment_id ]['meta'] ) ) {
			// Use the data from the batch cache, if available.
			$meta = maybe_unserialize( $this->attachments_meta[ $attachment_id ]['meta'] );
		} else {
			// This is unfiltered for performance, because we don't often need filtered meta.
			$meta = wp_get_attachment_metadata( $attachment_id, true );
		}
		$missing_meta_bail = true;
		if ( 'application/pdf' === $type ) {
			$missing_meta_bail = false;
			// If the upload is a PDF, only bail for missing meta for new uploads that have less than 5 attempts.
			if ( $item['attempts'] < 5 && $item['new'] ) {
				$missing_meta_bail = true;
			}
		}
		if ( in_array( $type, $supported_types, true ) && empty( $meta ) && $missing_meta_bail ) {
			ewwwio_debug_message( "metadata is missing, requeueing (previous attempts: {$item['attempts']})" );
			sleep( 4 );
			return $item;
		}
		$this->process_attachment( $meta, $item, $attachment_id );

		return false;
	}

	/**
	 * Check if an individual size from a media attachment should be (re)optimized.
	 *
	 * @param string $file_path The filesystem path for the image.
	 * @param string $size The thumb size (name).
	 * @param array  $item The async data for this attachment.
	 * @param array  $already_optimized The previous image results (if any) for this attachment.
	 * @return bool True if the image should be optimized, false otherwise.
	 */
	protected function should_optimize_size( $file_path, $size, $item, $already_optimized ) {
		\ewwwio_debug_message( '<b>' . __METHOD__ . '()</b>' );
		if ( \apply_filters( 'ewww_image_optimizer_bypass', false, $file_path ) ) {
			\ewwwio_debug_message( "skipping $file_path as instructed" );
			return false;
		}
		if ( ! \is_file( $file_path ) ) {
			\ewwwio_debug_message( "file does not exist: $file_path" );
			return false;
		}
		$image_size = filesize( $file_path );
		if ( ! $image_size ) {
			\ewwwio_debug_message( "file skipped due to no size: $file_path" );
			return false;
		}
		if ( $image_size < \ewww_image_optimizer_get_option( 'ewww_image_optimizer_skip_size' ) ) {
			\ewwwio_debug_message( "file skipped due to filesize: $file_path" );
			return false;
		}
		$mime       = ewww_image_optimizer_quick_mimetype( $file_path );
		$webp_types = ewwwio()->get_webp_types();
		if ( $item['webp_only'] && ! in_array( $mime, $webp_types, true ) ) {
			\ewwwio_debug_message( "not eligible for WebP conversion: $file_path" );
			return false;
		}
		if ( 'image/png' === $mime && \ewww_image_optimizer_get_option( 'ewww_image_optimizer_skip_png_size' ) && $image_size > \ewww_image_optimizer_get_option( 'ewww_image_optimizer_skip_png_size' ) ) {
			\ewwwio_debug_message( "file skipped due to PNG filesize: $file_path" );
			return false;
		}
		if ( 'image/bmp' === $mime && ! ewww_image_optimizer_get_option( 'ewww_image_optimizer_bmp_convert' ) && empty( $item['convert_once'] ) ) {
			\ewwwio_debug_message( "BMP skipped, no conversion enabled: $file_path" );
			return false;
		}
		$compression_level = \ewww_image_optimizer_get_level( $mime );
		if ( ! empty( $item['force_smart'] ) && ! \ewww_image_optimizer_level_mismatch( $already_optimized['level'], $compression_level ) ) {
			$item['force_smart'] = false;
		}
		if ( 'full' === $size && \ewww_image_optimizer_should_resize( $file_path, true ) ) {
			$item['force_smart'] = true;
		}
		if ( ! empty( $already_optimized['id'] ) && (int) $image_size === (int) $already_optimized['image_size'] ) {
			if ( empty( $item['force_reopt'] ) && empty( $item['force_smart'] ) && empty( $item['webp_only'] ) && empty( $item['new'] ) && empty( $item['convert_once'] ) ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Queue an individual size for a media attachment.
	 *
	 * @global object $wpdb
	 * @global string|array $optimized_list A list of all images that have been optimized, or a string
	 *                                      indicating why that is not a good idea.
	 *
	 * @param int    $id The attachment ID number.
	 * @param string $size The thumb size (name).
	 * @param string $file_path The filesystem path for the image.
	 * @param array  $item The async data for this attachment.
	 * @return int 1 if the image was queued, 0 if not.
	 */
	protected function queue_single_size( $id, $size, $file_path, $item ) {
		\ewwwio_debug_message( '<b>' . __METHOD__ . '()</b>' );
		global $wpdb;
		global $optimized_list;

		if ( is_array( $optimized_list ) && isset( $optimized_list[ $file_path ] ) ) {
			ewwwio_debug_message( "found already_optimized in memory for $file_path" );
			$already_optimized = $optimized_list[ $file_path ];
		} else {
			$already_optimized = \ewww_image_optimizer_find_already_optimized( $file_path );
		}

		$image_size = \ewww_image_optimizer_filesize( $file_path );
		\ewwwio_debug_message( "(maybe) queuing optimization for $id/$size" );
		if ( ! $this->should_optimize_size( $file_path, $size, $item, $already_optimized ) ) {
			\ewwwio_debug_message( 'already optimized, not forcing or webp-only, so skipping' );
			return 0;
		} elseif ( ! empty( $already_optimized['id'] ) ) {
			$wpdb->update(
				$wpdb->ewwwio_images,
				array(
					'pending'       => 1,
					'attachment_id' => $id,
					'gallery'       => 'media',
					'resize'        => $size,
					'updated'       => $already_optimized['updated'],
				),
				array(
					'id' => $already_optimized['id'],
				)
			);
			$id_to_queue = $already_optimized['id'];
			\ewwwio_debug_message( 'toggled db record' );
		} else {
			$wpdb->insert(
				$wpdb->ewwwio_images,
				array(
					'path'          => \ewww_image_optimizer_relativize_path( $file_path ),
					'converted'     => '',
					'gallery'       => 'media',
					'orig_size'     => $image_size,
					'attachment_id' => $id,
					'resize'        => $size,
					'pending'       => 1,
				)
			);
			$id_to_queue = $wpdb->insert_id;
			\ewwwio_debug_message( 'inserted db record' );
		}
		if ( ! $id_to_queue ) {
			\ewwwio_debug_message( 'failed to update/insert record, no ID to queue' );
			return 0;
		}
		ewwwio()->background_image->push_to_queue(
			array(
				'id'           => $id_to_queue,
				'new'          => $item['new'],
				'convert_once' => $item['convert_once'],
				'force_reopt'  => $item['force_reopt'],
				'force_smart'  => $item['force_smart'],
				'webp_only'    => $item['webp_only'],
			)
		);
		return 1;
	}

	/**
	 * Find image paths from an attachment's meta data and process each image.
	 *
	 * Called after `wp_generate_attachment_metadata` is completed (async), it also searches for retina images,
	 * and a few custom theme resizes.
	 *
	 * @global array $ewww_attachment {
	 *     Stores the ID and meta for later use with W3TC.
	 *
	 *     @type int $id The attachment ID number.
	 *     @type array $meta The attachment metadata from the postmeta table.
	 * }
	 *
	 * @param array $meta The attachment metadata generated by WordPress.
	 * @param array $item The async data for this attachment.
	 * @param int   $id The attachment ID number.
	 */
	protected function process_attachment( $meta, $item, $id ) {
		\ewwwio_debug_message( '<b>' . __METHOD__ . '()</b>' );
		if ( ! is_array( $meta ) && empty( $meta ) ) {
			$meta = array();
		} elseif ( ! is_array( $meta ) ) {
			ewwwio_debug_message( 'attachment meta is not a usable array' );
			return;
		}

		$size   = 'full';
		$queued = 0;
		ewwwio_debug_message( "attachment id: $id" );

		session_write_close();
		if ( $item['new'] ) {
			ewwwio_debug_message( 'this is a newly uploaded image from the async queue' );
			$new_image = true;
		} else {
			ewwwio_debug_message( 'this image is not a new upload' );
			$new_image = false;
		}

		$attached_file = ! empty( $this->attachments_meta[ $id ]['_wp_attached_file'] ) ? $this->attachments_meta[ $id ]['_wp_attached_file'] : '';

		$file_path = \ewww_image_optimizer_attachment_path( $meta, $id, $attached_file, false );

		/**
		 * Allow altering the metadata or performing other actions before the plugin processes an attachement.
		 *
		 * @param array  $meta The attachment metadata.
		 * @param string $file_path The file path to the image.
		 * @param bool   $new_image True if this is a newly uploaded image, false otherwise.
		 */
		$meta = apply_filters( 'ewww_image_optimizer_resize_from_meta_data', $meta, $file_path, $new_image );

		if ( ! $new_image && class_exists( 'Amazon_S3_And_CloudFront' ) && ewww_image_optimizer_stream_wrapped( $file_path ) ) {
			ewww_image_optimizer_check_table_as3cf( $meta, $id, $file_path );
		}
		if ( ! ewwwio_is_file( $file_path ) && class_exists( 'wpCloud\StatelessMedia\EWWW' ) && ! empty( $meta['gs_link'] ) ) {
			$file_path = ewww_image_optimizer_remote_fetch( $id, $meta );
		}
		// If the local file is missing and we have valid metadata, see if we can fetch via CDN.
		if ( ! ewwwio_is_file( $file_path ) || ewww_image_optimizer_stream_wrapped( $file_path ) ) {
			$file_path = ewww_image_optimizer_remote_fetch( $id, $meta );
			if ( ! $file_path ) {
				ewwwio_debug_message( 'could not retrieve path' );
				return;
			}
		}
		ewwwio_debug_message( "retrieved file path: $file_path" );

		$supported_types = ewwwio()->get_supported_types();
		$type            = ewww_image_optimizer_mimetype( $file_path, 'i' );
		if ( ! in_array( $type, $supported_types, true ) ) {
			ewwwio_debug_message( "mimetype not supported: $id" );
			\ewww_image_optimizer_post_optimize_attachment( $id );
			return;
		}

		// Queue the full-size image.
		$queued += $this->queue_single_size( $id, $size, $file_path, $item );
		// Then disable the conversion flag for all sub-sizes and derivatives.
		$item['convert_once'] = false;

		$hidpi_path = ewww_image_optimizer_get_hidpi_path( $file_path, true );
		$size      .= '-retina';
		if ( $hidpi_path ) {
			$queued += $this->queue_single_size( $id, $size, $hidpi_path, $item );
		}

		// Resized versions, so we can continue.
		if ( isset( $meta['sizes'] ) && ewww_image_optimizer_iterable( $meta['sizes'] ) ) {
			$disabled_sizes = ewww_image_optimizer_get_option( 'ewww_image_optimizer_disable_resizes_opt', false, true );
			ewwwio_debug_message( 'processing resizes' );
			// Process each resized version.
			$processed = array();
			foreach ( $meta['sizes'] as $size => $data ) {
				ewwwio_debug_message( "processing size: $size" );
				if ( strpos( $size, 'webp' ) === 0 ) {
					continue;
				}
				if ( ! empty( $disabled_sizes[ $size ] ) ) {
					continue;
				}
				if ( ! empty( $disabled_sizes['pdf-full'] ) && 'full' === $size ) {
					continue;
				}
				if ( empty( $data['file'] ) ) {
					continue;
				}
				ewwwio_debug_message( "maybe optimizing {$data['file']}" );
				// Check through all the sizes we've processed so far.
				foreach ( $processed as $proc => $scan ) {
					// If a previous resize had identical dimensions.
					if ( $scan['height'] === $data['height'] && $scan['width'] === $data['width'] ) {
						// We found a duplicate resize, so...
						// Point this resize at the same image as the previous one.
						$meta['sizes'][ $size ]['file'] = $meta['sizes'][ $proc ]['file'];
						if ( isset( $meta['sizes'][ $proc ]['mime-type'] ) ) {
							$meta['sizes'][ $size ]['mime-type'] = $meta['sizes'][ $proc ]['mime-type'];
						}
						continue( 2 );
					}
				}
				// If this is a unique size.
				$resize_path = str_replace( wp_basename( $file_path ), $data['file'], $file_path );
				if ( empty( $resize_path ) ) {
					ewwwio_debug_message( 'strange... $resize_path was empty' );
					continue;
				}
				if ( 'application/pdf' === $type && 'full' === $size ) {
					$size = 'pdf-full';
				}
				// Because some SVG plugins populate the resizes with the original path (since SVG is "scalable", of course).
				// Though it could happen for other types perhaps...
				if ( $resize_path === $file_path ) {
					continue;
				}

				$queued += $this->queue_single_size( $id, $size, $resize_path, $item );

				// Optimize retina images, if they exist.
				if ( function_exists( 'wr2x_get_retina' ) ) {
					$retina_path = wr2x_get_retina( $resize_path );
				} else {
					$retina_path = false;
				}
				if ( $retina_path && ewwwio_is_file( $retina_path ) ) {
					$queued += $this->queue_single_size( $id, $size . '-retina', $retina_path, $item );
				} else {
					$hidpi_path = ewww_image_optimizer_get_hidpi_path( $resize_path, true );
					if ( $hidpi_path ) {
						$queued += $this->queue_single_size( $id, $size . '-retina', $hidpi_path, $item );
					}
				}
				// Store info on the sizes we've processed, so we can check the list for duplicate sizes.
				$processed[ $size ]['width']  = $data['width'];
				$processed[ $size ]['height'] = $data['height'];
			} // End foreach().
		} // End if().

		// Original image detected.
		if ( isset( $meta['original_image'] ) && ewww_image_optimizer_get_option( 'ewww_image_optimizer_include_originals' ) ) {
			// Meta sizes don't contain a path, so we calculate one.
			$resize_path = trailingslashit( dirname( $file_path ) ) . $meta['original_image'];

			$queued += $this->queue_single_size( $id, 'original_image', $resize_path, $item );
		} // End if().

		// Process size from a custom theme.
		if ( isset( $meta['image_meta']['resized_images'] ) && ewww_image_optimizer_iterable( $meta['image_meta']['resized_images'] ) ) {
			$imagemeta_resize_pathinfo = pathinfo( $file_path );
			$imagemeta_resize_path     = '';
			foreach ( $meta['image_meta']['resized_images'] as $imagemeta_resize ) {
				$imagemeta_resize_path = $imagemeta_resize_pathinfo['dirname'] . '/' . $imagemeta_resize_pathinfo['filename'] . '-' . $imagemeta_resize . '.' . $imagemeta_resize_pathinfo['extension'];

				$queued += $this->queue_single_size( $id, '', $imagemeta_resize_path, $item );
			}
		}

		// And another custom theme.
		if ( isset( $meta['custom_sizes'] ) && ewww_image_optimizer_iterable( $meta['custom_sizes'] ) ) {
			$custom_sizes_pathinfo = pathinfo( $file_path );
			$custom_size_path      = '';
			foreach ( $meta['custom_sizes'] as $custom_size ) {
				$custom_size_path = $custom_sizes_pathinfo['dirname'] . '/' . $custom_size['file'];

				$queued += $this->queue_single_size( $id, '', $custom_size_path, $item );
			}
		}

		if ( $queued && ! ewwwio()->background_image->is_process_running() && ! \get_option( 'ewww_image_optimizer_pause_image_queue' ) ) {
			ewwwio()->background_image->dispatch();
		}
		if ( empty( $queued ) ) {
			\ewww_image_optimizer_post_optimize_attachment( $id );
		}
	}


	/**
	 * Runs failure routine for an item from the Media Library queue.
	 *
	 * @access protected
	 *
	 * @param array $item The id of the attachment, how many attempts have been made to process
	 *                    the item and whether it is a new upload.
	 */
	protected function failure( $item ) {
		if ( empty( $item['attachment_id'] ) ) {
			return;
		}
		\ewwwio_debug_message( '<b>' . __METHOD__ . '()</b>' );
		$file_path = false;
		$meta      = \wp_get_attachment_metadata( $item['attachment_id'] );
		if ( ! empty( $meta ) ) {
			$file_path = \ewww_image_optimizer_attachment_path( $meta, $item['attachment_id'] );
		}

		if ( $file_path ) {
			\ewww_image_optimizer_add_file_exclusion( $file_path );
		}
		\ewww_image_optimizer_post_optimize_attachment( $item['attachment_id'] );
	}

	/**
	 * Complete.
	 */
	protected function complete() {
		\ewwwio_debug_message( '<b>' . __METHOD__ . '()</b>' );
		parent::complete();
		if ( 'scanning' === \get_option( 'ewww_image_optimizer_bulk_resume' ) && ! \get_option( 'ewww_image_optimizer_bulk_foreground' ) ) {
			if ( ! \ewww_image_optimizer_get_option( 'ewww_image_optimizer_auto' ) && ! get_option( 'ewwwio_stop_scheduled_scan' ) ) {
				\ewwwio_debug_message( 'starting async scan' );
				\update_option( 'ewww_image_optimizer_aux_resume', 'scanning' );
				\ewwwio()->async_scan->data(
					array(
						'ewww_scan' => 'scheduled',
					)
				)->dispatch();
			}
			\ewwwio_debug_message( 'finished media queue' );
			\update_option( 'ewww_image_optimizer_bulk_resume', '' );
		}
	}
}
