<?php
/**
 * File: Extension_ImageService_Cron.php
 *
 * @since 2.2.0
 *
 * @package W3TC
 */

namespace W3TC;

/**
 * Class: Extension_ImageService_Cron
 *
 * @since 2.2.0
 */
class Extension_ImageService_Cron {
	/**
	 * Register the custom cron schedule and ensure the event is
	 * scheduled. Hooked on WordPress `init` because W3TC's extension
	 * bootstrap runs before `init`, and `add_schedule()` translates
	 * the display label.
	 *
	 * @since 2.10.0
	 * @static
	 */
	public static function register_cron() {
		add_filter( 'cron_schedules', array( __CLASS__, 'add_schedule' ) );
		self::add_cron();
	}

	/**
	 * Add cron job/event.
	 *
	 * @since 2.2.0
	 * @static
	 */
	public static function add_cron() {
		if ( ! wp_next_scheduled( 'w3tc_imageservice_cron' ) ) {
			wp_schedule_event( time(), 'ten_seconds', 'w3tc_imageservice_cron' );
		}
	}

	/**
	 * Add cron schedule.
	 *
	 * @since 2.2.0
	 * @static
	 *
	 * @param array $schedules Schedules.
	 */
	public static function add_schedule( array $schedules = array() ) {
		$schedules['ten_seconds'] = array(
			'interval' => 10,
			'display'  => esc_html__( 'Every Ten Seconds', 'w3-total-cache' ),
		);
		return $schedules;
	}

	/**
	 * Remove cron job/event.
	 *
	 * @since 2.2.0
	 * @static
	 */
	public static function delete_cron() {
		$timestamp = wp_next_scheduled( 'w3tc_imageservice_cron' );

		if ( $timestamp ) {
			wp_unschedule_event( $timestamp, 'w3tc_imageservice_cron' );
		}
	}

	/**
	 * Run the cron event.
	 *
	 * @since 2.2.0
	 *
	 * @see Extension_ImageService_Plugin_Admin::get_imageservice_attachments()
	 * @see Extension_ImageService_Plugin::get_api()
	 *
	 * @global $wp_filesystem WP_Filesystem.
	 */
	public static function run() {
		// Get all attachment post IDs with postmeta key "w3tc_imageservice".
		$results = Extension_ImageService_Plugin_Admin::get_imageservice_attachments();

		// If there are matches, then load dependencies before use.
		if ( $results->have_posts() ) {
			require_once __DIR__ . '/Extension_ImageService_Plugin_Admin.php';

			$wp_upload_dir = wp_upload_dir();

			global $wp_filesystem;

			// Make sure that this file is included, as wp_generate_attachment_metadata() depends on it.
			require_once ABSPATH . 'wp-admin/includes/image.php';
		}

		foreach ( $results->posts as $post_id ) {
			$postmeta = empty( $post_id ) ? null : get_post_meta( $post_id, 'w3tc_imageservice', true );
			$status   = isset( $postmeta['status'] ) ? $postmeta['status'] : null;

			// Handle new format with multiple jobs (processing_jobs) or old format (processing).
			$processing_jobs = isset( $postmeta['processing_jobs'] ) && is_array( $postmeta['processing_jobs'] ) ?
				$postmeta['processing_jobs'] : array();

			/**
			 * Backward compatibility: convert old format to new format.
			 * Only do this for items still marked as processing to avoid re-checking completed jobs.
			 */
			if ( 'processing' === $status && empty( $processing_jobs ) && isset( $postmeta['processing']['job_id'] ) && isset( $postmeta['processing']['signature'] ) ) {
				$processing_jobs = array(
					'webp' => array(
						'job_id'    => $postmeta['processing']['job_id'],
						'signature' => $postmeta['processing']['signature'],
						'mime_type' => 'image/webp',
					),
				);
			}

			/**
			 * Process items that have jobs to check, regardless of overall status.
			 * This ensures we continue processing even if some jobs complete and status changes.
			 */
			if ( ! empty( $processing_jobs ) ) {
				// Get the Image Service API object (singlton).
				$api = Extension_ImageService_Plugin::get_api();

				// Process each job separately.
				$all_jobs_ready = true;
				$has_error      = false;
				$has_notfound   = false;
				$jobs_status    = isset( $postmeta['jobs_status'] ) ? $postmeta['jobs_status'] : array();

				// Initialize arrays for storing multiple formats before processing jobs.
				$post_children = isset( $postmeta['post_children'] ) ? $postmeta['post_children'] : array();
				$downloads     = isset( $postmeta['downloads'] ) ? $postmeta['downloads'] : array();

				// Migrate legacy single-format data to multi-format storage when present.
				$legacy_child_id = isset( $postmeta['post_child'] ) ? $postmeta['post_child'] : null;
				$legacy_format   = 'webp';
				if ( $legacy_child_id ) {
					$legacy_post = get_post( $legacy_child_id );
					if ( $legacy_post && isset( $legacy_post->post_mime_type ) ) {
						$legacy_format = strtolower( str_replace( 'image/', '', $legacy_post->post_mime_type ) );
					}

					// Preserve legacy converted attachment reference.
					if ( empty( $post_children[ $legacy_format ] ) ) {
						$legacy_child_meta = get_post_meta( $legacy_child_id, 'w3tc_imageservice', true );
						if ( ! empty( $legacy_child_meta['is_converted_file'] ) ) {
							$post_children[ $legacy_format ] = $legacy_child_id;
						}
					}
				}

				// Preserve legacy download headers for stats display.
				if ( ! empty( $postmeta['download'] ) && empty( $downloads[ $legacy_format ] ) ) {
					$downloads[ $legacy_format ] = $postmeta['download'];
				}

				// Track which jobs are complete and should be removed from processing_jobs.
				$completed_jobs = array();

				foreach ( $processing_jobs as $format_key => $job ) {
					if ( ! isset( $job['job_id'] ) || ! isset( $job['signature'] ) ) {
						continue;
					}

					// Check the status of this job.
					$response = $api->get_status( $job['job_id'], $job['signature'] );

					// Store status for this job.
					$jobs_status[ $format_key ] = $response;

					// Stop checking jobs that are no longer found.
					if ( ( isset( $response['code'] ) && 404 === (int) $response['code'] ) || ( isset( $response['status'] ) && 'notfound' === $response['status'] ) ) {
						$has_notfound     = true;
						$all_jobs_ready   = false;
						$completed_jobs[] = $format_key;
						continue;
					}

					// Check if this job is ready for pickup/download.
					if ( isset( $response['status'] ) && 'pickup' === $response['status'] ) {
						// Get mime_type from job or response.
						$mime_type = isset( $job['mime_type'] ) ? $job['mime_type'] : ( isset( $response['mime_type'] ) ? $response['mime_type'] : 'image/webp' );

						/**
						 * Download image for this format using this job's job_id and signature.
						 * Pass mime_type_out to ensure API returns the correct format (e.g. AVIF vs WEBP).
						 */
						$download_response = $api->download( $job['job_id'], $job['signature'], $mime_type );
						$download_headers  = wp_remote_retrieve_headers( $download_response );
						$is_error          = isset( $download_response['error'] );

						// Convert headers to array and normalize keys to lowercase for consistent access.
						$headers_array = array();
						if ( ! $is_error && $download_headers ) {
							if ( is_object( $download_headers ) && method_exists( $download_headers, 'getAll' ) ) {
								// Requests_Utility_CaseInsensitiveDictionary - get all headers.
								$all_headers = $download_headers->getAll();
								foreach ( $all_headers as $w3tc_key => $w3tc_value ) {
									$headers_array[ strtolower( $w3tc_key ) ] = $w3tc_value;
								}
							} else {
								// Already an array or other structure.
								$temp_headers = (array) $download_headers;
								foreach ( $temp_headers as $w3tc_key => $w3tc_value ) {
									// Skip special WordPress array keys.
									if ( "\0" !== substr( $w3tc_key, 0, 1 ) ) {
										$headers_array[ strtolower( $w3tc_key ) ] = $w3tc_value;
									}
								}
								// Also check for the special data structure.
								if ( isset( $temp_headers["\0*\0data"] ) ) {
									foreach ( $temp_headers["\0*\0data"] as $w3tc_key => $w3tc_value ) {
										$headers_array[ strtolower( $w3tc_key ) ] = $w3tc_value;
									}
								}
							}
						}

						// Determine if file size was reduced by comparing input and output sizes.
						$filesize_in  = isset( $headers_array['x-filesize-in'] ) ? (int) $headers_array['x-filesize-in'] : 0;
						$filesize_out = isset( $headers_array['x-filesize-out'] ) ? (int) $headers_array['x-filesize-out'] : 0;
						$is_reduced   = ! $is_error && $filesize_in > 0 && $filesize_out > 0 && $filesize_out < $filesize_in;

						// Determine actual output mime type/format from headers (API may return a different output than requested).
						$mime_type_out  = isset( $headers_array['x-mime-type-out'] ) ? $headers_array['x-mime-type-out'] :
							( isset( $headers_array['content-type'] ) ? $headers_array['content-type'] : $mime_type );
						$format_key_out = strtolower( str_replace( 'image/', '', $mime_type_out ) );

						// Delete existing converted file for the actual output format if it exists.
						if ( isset( $post_children[ $format_key_out ] ) ) {
							wp_delete_attachment( $post_children[ $format_key_out ], true );
							unset( $post_children[ $format_key_out ] );
						}

						/**
						 * Store download information for this format (store normalized headers).
						 * Always store download info, even if there's an error or no size reduction.
						 */
						$downloads[ $format_key_out ] = $is_error ? $download_response['error'] : $headers_array;

						// If the job key doesn't match the actual output, remove any stale entry keyed by the job key.
						if ( $format_key_out !== $format_key ) {
							unset( $downloads[ $format_key ] );
							unset( $post_children[ $format_key ] );
						}

						// Skip saving file if error or if converted image is larger, but continue to next job.
						if ( $is_error ) {
							$has_error      = true;
							$all_jobs_ready = false;
							// Job completed (with error) - remove from processing_jobs.
							$completed_jobs[] = $format_key;
							continue;
						}

						if ( ! $is_reduced ) {
							// Image wasn't reduced (output is same size or larger), skip saving but continue to next job.
							$all_jobs_ready = false;
							// Job completed (no size reduction) - remove from processing_jobs.
							$completed_jobs[] = $format_key;
							continue;
						}

						/**
						 * RT9-226: Allowlist the output extension AND content-sniff the
						 * downloaded body before writing to disk. The original code took
						 * `format_key_out` straight from the API's `x-mime-type-out` /
						 * `content-type` header, so a compromised or MITM'd upstream
						 * could yield extensions like `.php` / `.htaccess` and bytes
						 * matching an arbitrary webshell. Both legs must agree:
						 *
						 * - Extension allowlist refuses everything outside the formats
						 * we actually request — `webp`, `avif`, `jpeg`, `jpg`, `png`,
						 * `gif`. (`jpg` accepted alongside `jpeg` because some clients
						 * normalise differently.)
						 * - Content-sniff via `getimagesizefromstring()` confirms the
						 * bytes really are an image — PHP source begins `<?php` and
						 * has no IHDR/SOI marker, so the sniff fails. AVIF is only
						 * recognised by PHP 8.1+, so for AVIF output on older PHP
						 * the ISO-BMFF "ftyp" box signature is verified manually
						 * via `is_avif_body()` instead.
						 *
						 * On either failure we drop into the same "error, no write,
						 * mark job complete" path the surrounding loop already uses.
						 */
						$body  = \wp_remote_retrieve_body( $download_response );
						$allow = array( 'webp', 'avif', 'jpeg', 'jpg', 'png', 'gif' );
						if ( ! \in_array( $format_key_out, $allow, true ) ) {
							$has_error                    = true;
							$all_jobs_ready               = false;
							$completed_jobs[]             = $format_key;
							$downloads[ $format_key_out ] = 'Refused: output format not in allowlist (' . $format_key_out . ').';
							continue;
						}
						if ( ! \is_string( $body ) || '' === $body ) {
							$has_error                    = true;
							$all_jobs_ready               = false;
							$completed_jobs[]             = $format_key;
							$downloads[ $format_key_out ] = 'Refused: empty download body.';
							continue;
						}
						$sniff_ok = false !== @\getimagesizefromstring( $body );
						if ( ! $sniff_ok && 'avif' === $format_key_out ) {
							$sniff_ok = self::is_avif_body( $body );
						}
						if ( ! $sniff_ok ) {
							$has_error                    = true;
							$all_jobs_ready               = false;
							$completed_jobs[]             = $format_key;
							$downloads[ $format_key_out ] = 'Refused: download body is not a recognised image.';
							continue;
						}

						// Get original file info for saving.
						$original_filepath = get_attached_file( $post_id );
						$original_size     = wp_getimagesize( $original_filepath );
						$original_filename = basename( get_attached_file( $post_id ) );
						$original_filedir  = str_replace( '/' . $original_filename, '', $original_filepath );

						// Save the file.
						$w3tc_extension = $format_key_out;
						$new_filename   = preg_replace( '/\.[^.]+$/', '', $original_filename ) . '.' . $w3tc_extension;
						$new_filepath   = $original_filedir . '/' . $new_filename;

						if ( is_a( $wp_filesystem, 'WP_Filesystem_Base' ) ) {
							$wp_filesystem->put_contents( $new_filepath, $body );
						} else {
							Util_File::file_put_contents_atomic( $new_filepath, $body );
						}

						// Insert as attachment post.
						$attachment_id = wp_insert_attachment(
							array(
								'guid'           => $new_filepath,
								'post_mime_type' => $mime_type_out,
								'post_title'     => preg_replace( '/\.[^.]+$/', '', $new_filename ),
								'post_content'   => '',
								'post_status'    => 'inherit',
								'post_parent'    => $post_id,
								'comment_status' => 'closed',
							),
							$new_filepath,
							$post_id,
							false,
							false
						);

						// Copy postmeta data to the new attachment.
						Extension_ImageService_Plugin_Admin::copy_postmeta( $post_id, $attachment_id );

						// Store the attachment ID for this format.
						$post_children[ $format_key_out ] = $attachment_id;

						// Mark the downloaded file as the converted one.
						Extension_ImageService_Plugin_Admin::update_postmeta(
							$attachment_id,
							array( 'is_converted_file' => true )
						);

						// In order to filter/hide converted files in the media list, add a meta key.
						update_post_meta( $attachment_id, 'w3tc_imageservice_file', $w3tc_extension );

						/**
						 * Generate the metadata for the attachment, and update the database record.
						 * On PHP < 8.1 `wp_getimagesize()` does not recognise AVIF, so
						 * `wp_generate_attachment_metadata()` returns an empty array for AVIF
						 * files; fall back to the original image dimensions and fill in the
						 * "file" and "filesize" entries manually.
						 */
						$attach_data           = wp_generate_attachment_metadata( $attachment_id, $new_filepath );
						$attach_data['width']  = isset( $attach_data['width'] ) ? $attach_data['width'] : $original_size[0];
						$attach_data['height'] = isset( $attach_data['height'] ) ? $attach_data['height'] : $original_size[1];
						if ( empty( $attach_data['file'] ) ) {
							$attach_data['file'] = _wp_relative_upload_path( $new_filepath );
						}
						if ( empty( $attach_data['filesize'] ) ) {
							$attach_data['filesize'] = \strlen( $body );
						}
						wp_update_attachment_metadata( $attachment_id, $attach_data );

						// Job successfully completed - remove from processing_jobs.
						$completed_jobs[] = $format_key;
					} elseif ( isset( $response['status'] ) && 'complete' === $response['status'] ) {
						// Job completed but no pickup - mark as error for this format.
						$has_error      = true;
						$all_jobs_ready = false;
						// Job completed (even with error) - remove from processing_jobs.
						$completed_jobs[] = $format_key;
					} else {
						// Job still processing - keep in processing_jobs.
						$all_jobs_ready = false;
					}
				}

				// Remove completed jobs from processing_jobs.
				foreach ( $completed_jobs as $format_key ) {
					unset( $processing_jobs[ $format_key ] );
				}

				// Save jobs status.
				Extension_ImageService_Plugin_Admin::update_postmeta(
					$post_id,
					array( 'jobs_status' => $jobs_status )
				);

				// Determine overall status based on all jobs.
				$has_converted = ! empty( $post_children );
				$status        = 'notconverted'; // Default status if no conversions were successful.
				if ( $has_converted ) {
					$status = 'converted';
				} elseif ( $has_error ) {
					$status = 'error';
				}
				if ( ! $has_converted && ! $has_error && $has_notfound && empty( $processing_jobs ) ) {
					$status = 'notfound';
				}

				// If there are still jobs processing, keep status as 'processing'.
				if ( ! empty( $processing_jobs ) ) {
					$status = 'processing';
				}

				// Save the download information, status, and remaining processing_jobs.
				Extension_ImageService_Plugin_Admin::update_postmeta(
					$post_id,
					array(
						'post_children'   => $post_children,
						'downloads'       => $downloads,
						'status'          => $status,
						'processing_jobs' => $processing_jobs,
					)
				);

				// For backward compatibility, also set post_child to the first converted format.
				if ( ! empty( $post_children ) ) {
					$first_format = reset( $post_children );
					Extension_ImageService_Plugin_Admin::update_postmeta(
						$post_id,
						array( 'post_child' => $first_format )
					);
				}
			}
		}
	}

	/**
	 * Determine whether a download body is an AVIF image.
	 *
	 * `getimagesizefromstring()` only recognises AVIF on PHP 8.1+, so on older
	 * PHP versions a valid AVIF download fails the content-sniff. Verify the
	 * ISO-BMFF "ftyp" box signature manually: bytes 4-7 must be "ftyp" and
	 * "avif"/"avis" must appear as the major brand (bytes 8-11) or among the
	 * compatible brands within the "ftyp" box.
	 *
	 * @since 2.10.0
	 * @static
	 *
	 * @param string $body Download body.
	 * @return bool
	 */
	public static function is_avif_body( $body ) {
		if ( ! \is_string( $body ) || \strlen( $body ) < 12 || 'ftyp' !== \substr( $body, 4, 4 ) ) {
			return false;
		}

		$major = \substr( $body, 8, 4 );
		if ( 'avif' === $major || 'avis' === $major ) {
			return true;
		}

		// Compatible brands follow the 4-byte minor version (offset 12).
		$box_size = \unpack( 'N', $body )[1];
		$box_size = \min( $box_size, \strlen( $body ) );
		for ( $offset = 16; $offset + 4 <= $box_size; $offset += 4 ) {
			$brand = \substr( $body, $offset, 4 );
			if ( 'avif' === $brand || 'avis' === $brand ) {
				return true;
			}
		}

		return false;
	}
}
