<?php
/**
 * BaseCloud Boost — Image Compressor
 *
 * Generates high-quality modern-format copies of JPEG/PNG uploads: WebP always
 * (when the platform can encode it), plus AVIF where the platform supports it
 * (GD on PHP 8.1+ with AVIF, or Imagick built with AVIF). Compression is
 * **non-destructive**: the original file is never modified or deleted — a
 * `.webp`/`.avif` sibling is written next to each image (and each registered
 * thumbnail size). {@see Image_Optimizer} then serves the smaller modern file
 * transparently to supporting browsers, falling back to the untouched original
 * everywhere else.
 *
 * Engine selection tries GD first (fastest, most common on shared hosting),
 * then Imagick, then whatever image editor WordPress has registered — so WebP
 * conversion works on hosts where only one of the libraries has WebP support.
 *
 * Quality defaults to a visually-lossless level, so large media libraries shrink
 * dramatically without any visible loss.
 *
 * @package BaseCloud\Boost\Optimizer
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

/**
 * Produces modern-format (WebP/AVIF) copies of images, on upload and in bulk.
 */
class Image_Compressor {

	/** Option key holding bulk-compression progress. */
	private const PROGRESS_KEY = 'bcboost_compress_progress';

	/** Option key holding lifetime compression stats. */
	private const STATS_KEY = 'bcboost_compress_stats';

	/** Source MIME types we generate modern formats from. */
	private const SOURCE_MIMES = [ 'image/jpeg', 'image/png' ];

	/** Target formats and their MIME types (order matters: WebP before AVIF). */
	private const FORMAT_MIMES = [
		'webp' => 'image/webp',
		'avif' => 'image/avif',
	];

	/** Most failure entries kept in the bulk progress option (newest win). */
	private const MAX_FAILURES = 50;

	/** Hard wall-clock cap (seconds) for one bulk batch / upload-time pass. */
	private const BATCH_TIME_CAP = 20.0;

	/**
	 * Hook generation onto new uploads.
	 *
	 * @return void
	 */
	public function boot(): void {
		if ( ! (bool) BCBOOST_get_option( 'compress_images', true ) ) {
			return;
		}

		// If the BunnyCDN Optimizer is converting images at the edge, generating
		// local .webp siblings on upload is redundant — stand down. (The bulk and
		// per-image Media Library tools still work for anyone who wants local copies.)
		if ( BCBOOST_bunny_optimizer_active() ) {
			return;
		}

		// No capable encoder at all — nothing to hook (the admin UI explains why).
		if ( ! self::webp_supported() && ! self::avif_supported() ) {
			return;
		}

		// Runs after WordPress has created the full-size image and all thumbnails.
		add_filter( 'wp_generate_attachment_metadata', [ $this, 'on_generate_metadata' ], 20, 2 );
	}

	// ─── Engine detection (GD first, then Imagick, then the WP editor chain) ─────

	/**
	 * Whether the server can actually encode WebP (GD, Imagick, or any WP editor).
	 *
	 * @return bool
	 */
	public static function webp_supported(): bool {
		return null !== self::engine_for( 'webp' );
	}

	/**
	 * Whether the server can actually encode AVIF (GD on PHP 8.1+, or Imagick).
	 *
	 * @return bool
	 */
	public static function avif_supported(): bool {
		return null !== self::engine_for( 'avif' );
	}

	/**
	 * Which engine will encode a format: 'gd' | 'imagick' | 'editor' | null.
	 *
	 * GD is preferred (fastest and most common on shared hosting), then Imagick,
	 * then whatever editor WordPress has registered (covers custom editors).
	 *
	 * @param string $format 'webp' or 'avif'.
	 * @return string|null
	 */
	public static function engine_for( string $format ): ?string {
		static $memo = [];
		if ( array_key_exists( $format, $memo ) ) {
			return $memo[ $format ];
		}

		$engine = null;
		if ( self::gd_supports( $format ) ) {
			$engine = 'gd';
		} elseif ( self::imagick_supports( $format ) ) {
			$engine = 'imagick';
		} elseif ( isset( self::FORMAT_MIMES[ $format ] )
			&& function_exists( 'wp_image_editor_supports' )
			&& wp_image_editor_supports( [ 'mime_type' => self::FORMAT_MIMES[ $format ] ] ) ) {
			$engine = 'editor';
		}

		$memo[ $format ] = $engine;
		return $engine;
	}

	/**
	 * Can GD encode this format?
	 *
	 * @param string $format 'webp' or 'avif'.
	 * @return bool
	 */
	public static function gd_supports( string $format ): bool {
		if ( ! function_exists( 'gd_info' ) ) {
			return false;
		}
		$info = (array) gd_info();
		if ( 'webp' === $format ) {
			return function_exists( 'imagewebp' ) && ! empty( $info['WebP Support'] );
		}
		if ( 'avif' === $format ) {
			// imageavif() exists on PHP 8.1+ when GD is built with AVIF.
			return function_exists( 'imageavif' ) && ! empty( $info['AVIF Support'] );
		}
		return false;
	}

	/**
	 * Can Imagick encode this format?
	 *
	 * @param string $format 'webp' or 'avif'.
	 * @return bool
	 */
	public static function imagick_supports( string $format ): bool {
		if ( ! class_exists( '\Imagick' ) ) {
			return false;
		}
		try {
			return count( (array) \Imagick::queryFormats( strtoupper( $format ) ) ) > 0;
		} catch ( \Throwable $e ) {
			return false;
		}
	}

	/**
	 * Human-readable name of the engine that will encode a format.
	 *
	 * @param string $format 'webp' or 'avif'.
	 * @return string
	 */
	public static function engine_label( string $format ): string {
		$engine = self::engine_for( $format );
		if ( 'gd' === $engine ) {
			return 'GD';
		}
		if ( 'imagick' === $engine ) {
			return 'Imagick';
		}
		if ( 'editor' === $engine ) {
			return __( 'WordPress image editor', 'basecloud-boost' );
		}
		return __( 'not available', 'basecloud-boost' );
	}

	/**
	 * Why a format cannot be generated on this server (shown in the admin UI).
	 *
	 * @param string $format 'webp' or 'avif'.
	 * @return string
	 */
	public static function unsupported_reason( string $format = 'webp' ): string {
		if ( 'avif' === $format ) {
			return __( "Your server's PHP cannot create AVIF files — this needs GD with AVIF support (PHP 8.1+) or Imagick compiled with AVIF. Ask your host about enabling it.", 'basecloud-boost' );
		}
		return __( "Your server's PHP has no WebP support (GD/Imagick) — ask your host to enable it.", 'basecloud-boost' );
	}

	/**
	 * Formats this run should generate. WebP always (when encodable); AVIF only
	 * when the platform supports it AND "Serve AVIF" is enabled — AVIF encoding
	 * is expensive, so we never burn CPU on files that would not be served.
	 * WebP is deliberately first so the AVIF "must beat WebP" size guard works.
	 *
	 * AVIF additionally requires a decoder to validate the encoder's output
	 * (bfap.co.za field bug: a host's AVIF encoder emitted complete files whose
	 * headers parsed but whose payload rendered blank in Chrome — only a real
	 * decode catches that). No validator ⇒ no AVIF on this host, logged once.
	 *
	 * @return string[]
	 */
	private function target_formats(): array {
		$formats = [];
		if ( self::webp_supported() ) {
			$formats[] = 'webp';
		}
		if ( (bool) BCBOOST_get_option( 'serve_avif', false ) && self::avif_supported() ) {
			if ( self::can_validate( 'avif' ) ) {
				$formats[] = 'avif';
			} else {
				self::log_once( 'avif-no-validator', 'AVIF generation disabled on this host: no AVIF decoder (GD imagecreatefromavif or Imagick) is available to validate encoder output, and unvalidated AVIF has shipped broken images in the field (bfap.co.za). WebP generation is unaffected.' );
			}
		}
		return $formats;
	}

	// ─── Upload hook ─────────────────────────────────────────────────────────────

	/**
	 * Compress a freshly-generated attachment's files.
	 *
	 * @param array $metadata      Attachment metadata.
	 * @param int   $attachment_id Attachment ID.
	 * @return array Unmodified metadata (so we never interfere with the upload pipeline).
	 */
	public function on_generate_metadata( $metadata, $attachment_id ) {
		if ( is_array( $metadata ) ) {
			try {
				// Time-budgeted so a slow conversion can never kill the upload
				// request; leftover files are picked up by a later bulk/on-demand run.
				$this->compress_attachment( (int) $attachment_id, $metadata, microtime( true ) + $this->time_budget() );
			} catch ( \Throwable $e ) {
				self::log( 'upload-time compression failed for #' . (int) $attachment_id . ': ' . $e->getMessage() );
			}
		}
		return $metadata;
	}

	// ─── Conversion ──────────────────────────────────────────────────────────────

	/**
	 * Generate modern-format copies for an attachment (full size + every thumbnail).
	 *
	 * Never throws: per-file errors are collected in the returned `failures` list
	 * so callers (uploads, Media Library actions, bulk, CLI) can never be fataled
	 * by a single bad image.
	 *
	 * @param int        $attachment_id Attachment ID.
	 * @param array|null $metadata      Optional pre-loaded metadata.
	 * @param float|null $deadline      Optional absolute microtime() deadline; the
	 *                                  pass stops cleanly between files when reached.
	 * @return array{generated:int, skipped:int, bytes_saved:int, failures:array<int,array{file:string,reason:string}>, timed_out:bool}
	 */
	public function compress_attachment( int $attachment_id, ?array $metadata = null, ?float $deadline = null ): array {
		$result = [ 'generated' => 0, 'skipped' => 0, 'bytes_saved' => 0, 'failures' => [], 'timed_out' => false ];

		try {
			$mime = (string) get_post_mime_type( $attachment_id );
			if ( ! in_array( $mime, self::SOURCE_MIMES, true ) ) {
				return $result;
			}

			$files   = $this->collect_attachment_files( $attachment_id, $metadata );
			$formats = $this->target_formats();
			if ( empty( $files ) || empty( $formats ) ) {
				return $result;
			}

			foreach ( $files as $file ) {
				foreach ( $formats as $format ) {
					if ( null !== $deadline && microtime( true ) >= $deadline ) {
						$result['timed_out'] = true;
						break 2;
					}

					try {
						$saved = $this->generate_modern_for_file( $file, $format );
						if ( null === $saved ) {
							$result['skipped']++;
						} else {
							$result['generated']++;
							$result['bytes_saved'] += $saved;
						}
					} catch ( \Throwable $e ) {
						$result['failures'][] = [
							'file'   => basename( $file ) . ' (' . $format . ')',
							'reason' => $e->getMessage(),
						];
						self::log( basename( $file ) . ' -> ' . $format . ' failed: ' . $e->getMessage() );
					}
				}
			}
		} catch ( \Throwable $e ) {
			// Fail open: compression must never break uploads or the Media Library.
			$result['failures'][] = [
				'file'   => 'attachment #' . $attachment_id,
				'reason' => $e->getMessage(),
			];
			self::log( 'attachment #' . $attachment_id . ' failed: ' . $e->getMessage() );
		}

		if ( $result['generated'] > 0 ) {
			$this->record_stats( $result['generated'], $result['bytes_saved'] );
		}

		return $result;
	}

	/**
	 * The full-size file plus each registered thumbnail size for an attachment.
	 *
	 * @param int        $attachment_id Attachment ID.
	 * @param array|null $metadata      Optional pre-loaded metadata.
	 * @return string[] Absolute file paths (deduplicated).
	 */
	private function collect_attachment_files( int $attachment_id, ?array $metadata = null ): array {
		$full_path = get_attached_file( $attachment_id );
		if ( ! $full_path || ! file_exists( $full_path ) ) {
			return [];
		}

		if ( null === $metadata ) {
			$metadata = wp_get_attachment_metadata( $attachment_id );
		}

		$files = [ $full_path ];
		$dir   = trailingslashit( dirname( $full_path ) );

		if ( is_array( $metadata ) && ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
			foreach ( $metadata['sizes'] as $size ) {
				if ( ! empty( $size['file'] ) ) {
					$files[] = $dir . $size['file'];
				}
			}
		}

		return array_values( array_unique( $files ) );
	}

	/**
	 * Back-compat wrapper: create a `.webp` sibling for a single source image.
	 * Never throws (preserves the original ?int contract).
	 *
	 * @param string $source_path Absolute path to a JPEG/PNG file.
	 * @return int|null Bytes saved versus the source, or null when skipped/failed.
	 */
	public function generate_webp_for_file( string $source_path ): ?int {
		try {
			return $this->generate_modern_for_file( $source_path, 'webp' );
		} catch ( \Throwable $e ) {
			self::log( basename( $source_path ) . ' -> webp failed: ' . $e->getMessage() );
			return null;
		}
	}

	/**
	 * Create a modern-format sibling for a single source image.
	 *
	 * Tries GD first, then Imagick, then the WordPress editor chain — with the
	 * same quality setting on every engine.
	 *
	 * @param string $source_path Absolute path to a JPEG/PNG file.
	 * @param string $format      Target format: 'webp' or 'avif'.
	 * @return int|null Bytes saved versus the source, or null when skipped
	 *                  (already fresh, not applicable, or result not smaller).
	 * @throws \RuntimeException When every capable engine failed to convert.
	 */
	public function generate_modern_for_file( string $source_path, string $format = 'webp' ): ?int {
		if ( ! isset( self::FORMAT_MIMES[ $format ] ) || ! file_exists( $source_path ) ) {
			return null;
		}

		// Only JPEG/PNG sources.
		if ( ! preg_match( '/\.(jpe?g|png)$/i', $source_path ) ) {
			return null;
		}

		$dest_path = (string) preg_replace( '/\.(jpe?g|png)$/i', '.' . $format, $source_path );
		if ( '' === $dest_path || $dest_path === $source_path ) {
			return null;
		}

		$dest_was_broken = false;
		if ( file_exists( $dest_path ) ) {
			// A file already owns the destination name. If its pixel dimensions
			// differ from the source it is NOT our previous conversion — it is a
			// separate media upload that happens to share the basename (see the
			// quatro.co.za `Hygiene_Services.webp` collision). That file is the
			// client's media: never overwrite it, and never serve it as ours.
			if ( ! Image_Optimizer::dims_match( $source_path, $dest_path ) ) {
				return null;
			}
			if ( Image_Optimizer::looks_truncated( $dest_path ) ) {
				// Dimensions match, so it IS our conversion — but a previous
				// worker died mid-write (the bfap.co.za broken-AVIF mode).
				// Regenerate it regardless of how fresh its mtime looks.
				$dest_was_broken = true;
			} elseif ( filemtime( $dest_path ) >= filemtime( $source_path ) ) {
				// Our conversion and still fresh — skip (this is what makes re-runs safe).
				return null;
			}
		}

		// AVIF output must be decode-validated before it is ever served (bfap:
		// complete-but-undecodable encoder output). No decoder ⇒ no AVIF.
		if ( 'avif' === $format && ! self::can_validate( 'avif' ) ) {
			self::log_once( 'avif-no-validator', 'AVIF generation disabled on this host: no AVIF decoder (GD imagecreatefromavif or Imagick) is available to validate encoder output, and unvalidated AVIF has shipped broken images in the field (bfap.co.za). WebP generation is unaffected.' );
			return null;
		}

		// GD first, then Imagick, then whatever editor WordPress has registered.
		$attempts = [];
		if ( self::gd_supports( $format ) ) {
			$attempts[] = 'gd';
		}
		if ( self::imagick_supports( $format ) ) {
			$attempts[] = 'imagick';
		}
		if ( empty( $attempts )
			&& function_exists( 'wp_image_editor_supports' )
			&& wp_image_editor_supports( [ 'mime_type' => self::FORMAT_MIMES[ $format ] ] ) ) {
			$attempts[] = 'editor';
		}
		if ( empty( $attempts ) ) {
			return null; // Platform cannot encode this format — quiet skip; callers gate on *_supported().
		}

		// Atomic generation: every engine writes to a unique temp name in the
		// SAME directory; the destination is only ever touched by a rename() of
		// a fully-written, decode-validated file. A worker killed mid-write now
		// leaves only harmless `.tmp` litter (swept by verify-conversions) —
		// never a truncated .webp/.avif that could be served (bfap.co.za).
		$tmp_path = $dest_path . '.' . uniqid( '', false ) . '.tmp';
		$quality  = $this->quality();
		$errors   = [];
		$written  = false;

		foreach ( $attempts as $engine ) {
			try {
				if ( 'gd' === $engine ) {
					$written = $this->convert_with_gd( $source_path, $tmp_path, $format, $quality );
				} elseif ( 'imagick' === $engine ) {
					$written = $this->convert_with_imagick( $source_path, $tmp_path, $format, $quality );
				} else {
					$written = $this->convert_with_wp_editor( $source_path, $tmp_path, $format, $quality );
				}
			} catch ( \Throwable $e ) {
				$errors[] = strtoupper( $engine ) . ': ' . $e->getMessage();
				$written  = false;
			}

			// The output must actually DECODE — existence is not integrity. An
			// independent decoder is preferred over the engine that encoded it,
			// because a broken encoder's round-trip can accept its own garbage.
			if ( $written && ! $this->validate_modern_file( $tmp_path, $format, $source_path, $engine ) ) {
				$errors[] = strtoupper( $engine ) . ': output failed decode validation';
				$written  = false;
			}

			if ( ! $written && file_exists( $tmp_path ) ) {
				@unlink( $tmp_path );
			}
			if ( $written ) {
				break;
			}
		}

		if ( ! $written || ! file_exists( $tmp_path ) ) {
			if ( file_exists( $tmp_path ) ) {
				@unlink( $tmp_path ); // Never leave a partial file behind.
			}
			throw new \RuntimeException( $errors ? implode( ' | ', $errors ) : 'conversion produced no file' );
		}

		$src_size = (int) filesize( $source_path );
		$new_size = (int) filesize( $tmp_path );

		// If the modern copy somehow ended up larger (rare for already-tiny PNGs),
		// discard it so we never serve a heavier file.
		if ( $new_size >= $src_size && $src_size > 0 ) {
			@unlink( $tmp_path );
			$this->discard_broken_dest( $dest_path, $dest_was_broken );
			return null;
		}

		// AVIF is served with priority over WebP, so only keep an AVIF that
		// actually beats its WebP sibling — anything else would be a regression.
		if ( 'avif' === $format ) {
			$webp_sibling = (string) preg_replace( '/\.avif$/i', '.webp', $dest_path );
			if ( $webp_sibling !== $dest_path && file_exists( $webp_sibling ) ) {
				$webp_size = (int) filesize( $webp_sibling );
				if ( $webp_size > 0 && $new_size >= $webp_size ) {
					@unlink( $tmp_path );
					$this->discard_broken_dest( $dest_path, $dest_was_broken );
					return null;
				}
			}
		}

		// Publish atomically (rename within one directory = same filesystem):
		// the destination is only ever the old complete file or the new
		// validated file — never a partial write.
		if ( ! @rename( $tmp_path, $dest_path ) ) {
			@unlink( $tmp_path );
			throw new \RuntimeException( 'could not move validated ' . $format . ' into place' );
		}
		Image_Optimizer::forget_file( $dest_path );

		return max( 0, $src_size - $new_size );
	}

	/**
	 * When a regeneration is discarded (result larger than source / AVIF not
	 * beating WebP), a destination already known to be truncated must not stay
	 * on disk masquerading as a conversion.
	 *
	 * @param string $dest_path       Destination path.
	 * @param bool   $dest_was_broken Whether the pre-existing file was truncated.
	 * @return void
	 */
	private function discard_broken_dest( string $dest_path, bool $dest_was_broken ): void {
		if ( $dest_was_broken && file_exists( $dest_path ) ) {
			@unlink( $dest_path );
			Image_Optimizer::forget_file( $dest_path );
		}
	}

	/**
	 * Encode with GD.
	 *
	 * @param string $source  Source path (JPEG/PNG).
	 * @param string $dest    Destination path.
	 * @param string $format  'webp' or 'avif'.
	 * @param int    $quality 40–100.
	 * @return bool
	 * @throws \RuntimeException On decode/encode failure or insufficient memory.
	 */
	private function convert_with_gd( string $source, string $dest, string $format, int $quality ): bool {
		$info = @getimagesize( $source );
		if ( ! is_array( $info ) || empty( $info[0] ) || empty( $info[1] ) ) {
			throw new \RuntimeException( 'unreadable image' );
		}

		// Preflight the decode so a huge original raises a clean, catchable error
		// instead of an uncatchable out-of-memory fatal mid-batch.
		$this->assert_gd_memory( (int) $info[0], (int) $info[1], (int) $info[2] );

		if ( IMAGETYPE_JPEG === $info[2] && function_exists( 'imagecreatefromjpeg' ) ) {
			$im = @imagecreatefromjpeg( $source );
		} elseif ( IMAGETYPE_PNG === $info[2] && function_exists( 'imagecreatefrompng' ) ) {
			$im = @imagecreatefrompng( $source );
		} else {
			throw new \RuntimeException( 'GD cannot decode this image type' );
		}

		if ( ! $im ) {
			throw new \RuntimeException( 'GD failed to decode the image' );
		}

		// WebP/AVIF encoders need truecolor; keep PNG transparency intact.
		if ( IMAGETYPE_PNG === $info[2] ) {
			if ( function_exists( 'imagepalettetotruecolor' ) ) {
				@imagepalettetotruecolor( $im );
			}
			@imagealphablending( $im, false );
			@imagesavealpha( $im, true );
		}

		if ( 'webp' === $format ) {
			$ok = @imagewebp( $im, $dest, $quality );
		} elseif ( 'avif' === $format && function_exists( 'imageavif' ) ) { // PHP 8.1+ only.
			$ok = @imageavif( $im, $dest, $quality );
		} else {
			$ok = false;
		}

		if ( function_exists( 'imagedestroy' ) ) {
			@imagedestroy( $im );
		}

		if ( ! $ok ) {
			@unlink( $dest );
			throw new \RuntimeException( 'GD failed to encode ' . $format );
		}
		return true;
	}

	/**
	 * Fail cleanly (catchable) when GD would exhaust PHP memory decoding an image.
	 *
	 * @param int $width  Pixel width.
	 * @param int $height Pixel height.
	 * @param int $type   IMAGETYPE_* constant.
	 * @return void
	 * @throws \RuntimeException When the decode would not fit in memory.
	 */
	private function assert_gd_memory( int $width, int $height, int $type ): void {
		$limit = wp_convert_hr_to_bytes( (string) ini_get( 'memory_limit' ) );
		if ( $limit <= 0 ) {
			return; // Unlimited.
		}
		$bytes_per_pixel = ( IMAGETYPE_PNG === $type ) ? 5 : 4; // RGBA + palette overhead.
		$needed          = (int) ( $width * $height * $bytes_per_pixel * 1.65 ); // Decode + encode headroom.
		$available       = $limit - memory_get_usage( true );
		if ( $needed > $available ) {
			throw new \RuntimeException( sprintf( 'image too large for available PHP memory (%dx%d px)', $width, $height ) );
		}
	}

	/**
	 * Encode with Imagick.
	 *
	 * @param string $source  Source path (JPEG/PNG).
	 * @param string $dest    Destination path.
	 * @param string $format  'webp' or 'avif'.
	 * @param int    $quality 40–100 (same scale as GD, so output is consistent).
	 * @return bool
	 * @throws \RuntimeException|\ImagickException On failure.
	 */
	private function convert_with_imagick( string $source, string $dest, string $format, int $quality ): bool {
		$im = new \Imagick();
		try {
			$im->readImage( $source );
			$im->setImageFormat( $format );
			$im->setImageCompressionQuality( $quality );
			if ( 'avif' === $format ) {
				// Balanced encode effort — full-effort AVIF can take minutes per image.
				$im->setOption( 'heic:speed', '6' );
			}
			// Explicit `format:` prefix: the caller hands us a `.tmp` path (atomic
			// write-then-rename), and Imagick must not infer the format from that
			// extension.
			$ok = $im->writeImage( $format . ':' . $dest );
		} finally {
			$im->clear();
		}

		if ( ! $ok ) {
			@unlink( $dest );
			throw new \RuntimeException( 'Imagick failed to write ' . $format );
		}
		return true;
	}

	/**
	 * Encode with whatever image editor WordPress has registered (last resort).
	 *
	 * @param string $source  Source path.
	 * @param string $dest    Destination path.
	 * @param string $format  'webp' or 'avif'.
	 * @param int    $quality 40–100.
	 * @return bool
	 * @throws \RuntimeException On failure.
	 */
	private function convert_with_wp_editor( string $source, string $dest, string $format, int $quality ): bool {
		$editor = wp_get_image_editor( $source );
		if ( is_wp_error( $editor ) ) {
			throw new \RuntimeException( $editor->get_error_message() );
		}

		$editor->set_quality( $quality );
		$outcome = $editor->save( $dest, self::FORMAT_MIMES[ $format ] );

		if ( is_wp_error( $outcome ) ) {
			throw new \RuntimeException( $outcome->get_error_message() );
		}
		if ( empty( $outcome['path'] ) || ! file_exists( $outcome['path'] ) ) {
			throw new \RuntimeException( 'editor reported no output file' );
		}

		// The editor may write under its own name; normalise onto the requested
		// path. The caller hands us a temp path, so this rename happens BEFORE
		// decode validation and BEFORE the final atomic publish — the real
		// destination is never touched by an unvalidated file.
		if ( $outcome['path'] !== $dest && ! @rename( $outcome['path'], $dest ) ) {
			@unlink( $outcome['path'] );
			throw new \RuntimeException( 'could not normalise editor output onto the temp path' );
		}
		return file_exists( $dest );
	}

	// ─── Post-write integrity validation ─────────────────────────────────────────

	/**
	 * Whether this host can decode-validate a format at all.
	 *
	 * @param string $format 'webp' or 'avif'.
	 * @return bool
	 */
	public static function can_validate( string $format ): bool {
		return [] !== self::decode_engines( $format );
	}

	/**
	 * Decoders available for a format, ordered by preference.
	 *
	 * Cross-decoder rule (bfap.co.za): when `$encoded_by` is known, an
	 * INDEPENDENT decoder is preferred — a broken encoder's own round-trip may
	 * happily accept output that no browser can render.
	 *
	 * @param string      $format     'webp' or 'avif'.
	 * @param string|null $encoded_by Engine that produced the file, when known.
	 * @return string[] Subset of ['gd','imagick'], preference-ordered.
	 */
	private static function decode_engines( string $format, ?string $encoded_by = null ): array {
		$engines = [];
		if ( ( 'webp' === $format && function_exists( 'imagecreatefromwebp' ) )
			|| ( 'avif' === $format && function_exists( 'imagecreatefromavif' ) ) ) {
			$engines[] = 'gd';
		}
		if ( self::imagick_supports( $format ) ) {
			$engines[] = 'imagick';
		}

		if ( null !== $encoded_by && count( $engines ) > 1 ) {
			$independent = array_values( array_diff( $engines, [ $encoded_by ] ) );
			$engines     = array_merge( $independent, array_values( array_intersect( $engines, [ $encoded_by ] ) ) );
		}
		return $engines;
	}

	/**
	 * Validate that a generated modern-format file is actually servable.
	 *
	 * Layered, cheapest first:
	 * 1. Structural sanity — magic bytes, RIFF/ISO-BMFF container size
	 *    consistency, minimum plausible byte floor for the pixel area
	 *    ({@see Image_Optimizer::looks_truncated()}).
	 * 2. A REAL decode with the preferred (independent-when-possible) decoder:
	 *    GD `imagecreatefromwebp`/`imagecreatefromavif`, or Imagick `readImage`.
	 * 3. Pixel sanity on the decoded image: dimensions must match the source,
	 *    and output that decodes to one flat colour is rejected unless the
	 *    source itself is flat (bfap "renders blank" mode).
	 *
	 * The FIRST available decoder's verdict is final — falling back to a second
	 * decoder on failure would let a broken engine outvote a working one.
	 *
	 * @param string      $path        Absolute path of the candidate file (may be a temp name).
	 * @param string      $format      'webp' or 'avif'.
	 * @param string|null $source_path Original JPEG/PNG, for dimension/flatness comparison.
	 * @param string|null $encoded_by  Engine that produced the file, when known.
	 * @return bool True when the file is safe to publish/serve.
	 */
	public function validate_modern_file( string $path, string $format, ?string $source_path = null, ?string $encoded_by = null ): bool {
		clearstatcache( true, $path );
		if ( ! is_file( $path ) ) {
			return false;
		}

		if ( Image_Optimizer::looks_truncated( $path ) ) {
			return false;
		}

		$engines = self::decode_engines( $format, $encoded_by );
		if ( [] === $engines ) {
			// No decoder on this host: the structural checks above are the best
			// available. WebP proceeds on them; AVIF generation is gated off
			// entirely upstream ({@see target_formats()}).
			return true;
		}

		return 'gd' === $engines[0]
			? $this->validate_with_gd( $path, $format, $source_path )
			: $this->validate_with_imagick( $path, $source_path );
	}

	/**
	 * Decode validation via GD.
	 *
	 * @param string      $path        Candidate file.
	 * @param string      $format      'webp' or 'avif'.
	 * @param string|null $source_path Original image, when known.
	 * @return bool
	 */
	private function validate_with_gd( string $path, string $format, ?string $source_path ): bool {
		// Preflight memory for the decode where the header is parseable (AVIF
		// headers need PHP 8.1+). If the host cannot afford the decode, the
		// structural checks already passed — do not fail a plausibly-good file.
		$info = @getimagesize( $path );
		if ( is_array( $info ) && ! empty( $info[0] ) && ! empty( $info[1] ) ) {
			try {
				$this->assert_gd_memory( (int) $info[0], (int) $info[1], (int) ( $info[2] ?? 0 ) );
			} catch ( \Throwable $e ) {
				return true;
			}
		}

		if ( 'webp' === $format && function_exists( 'imagecreatefromwebp' ) ) {
			$im = @imagecreatefromwebp( $path );
		} elseif ( 'avif' === $format && function_exists( 'imagecreatefromavif' ) ) {
			$im = @imagecreatefromavif( $path );
		} else {
			return true; // Caller guarantees this cannot happen; fail open.
		}

		if ( ! $im ) {
			return false; // The payload does not decode — broken, period.
		}

		$ok = true;
		$w  = (int) imagesx( $im );
		$h  = (int) imagesy( $im );
		if ( $w < 1 || $h < 1 ) {
			$ok = false;
		}

		// A conversion never resizes: decoded dimensions must match the source.
		if ( $ok && null !== $source_path ) {
			$src = @getimagesize( $source_path );
			if ( is_array( $src ) && ! empty( $src[0] ) && ( (int) $src[0] !== $w || (int) $src[1] !== $h ) ) {
				$ok = false;
			}
		}

		// Flat-output guard: garbage that decodes to a single solid colour when
		// the source is not flat (the bfap blank-render failure mode).
		if ( $ok && null !== $source_path && null !== ( $flat = $this->gd_flat_colour( $im, $w, $h ) )
			&& ! $this->source_is_flat_gd( $source_path ) ) {
			$ok = false;
		}

		if ( function_exists( 'imagedestroy' ) ) {
			@imagedestroy( $im );
		}
		return $ok;
	}

	/**
	 * Decode validation via Imagick.
	 *
	 * @param string      $path        Candidate file.
	 * @param string|null $source_path Original image, when known.
	 * @return bool
	 */
	private function validate_with_imagick( string $path, ?string $source_path ): bool {
		try {
			$im = new \Imagick();
			try {
				$im->readImage( $path ); // Full decode — pingImage() only parses headers.

				$w = (int) $im->getImageWidth();
				$h = (int) $im->getImageHeight();
				if ( $w < 1 || $h < 1 ) {
					return false;
				}

				if ( null !== $source_path ) {
					$src = @getimagesize( $source_path );
					if ( is_array( $src ) && ! empty( $src[0] ) && ( (int) $src[0] !== $w || (int) $src[1] !== $h ) ) {
						return false;
					}
					if ( null !== $this->imagick_flat_colour( $im, $w, $h ) && ! $this->source_is_flat_imagick( $source_path ) ) {
						return false;
					}
				}
				return true;
			} finally {
				$im->clear();
			}
		} catch ( \Throwable $e ) {
			return false; // readImage threw — the payload does not decode.
		}
	}

	/**
	 * Sample points (relative coordinates) used by the flatness checks.
	 *
	 * @param int $w Width.
	 * @param int $h Height.
	 * @return array<int,array{0:int,1:int}>
	 */
	private static function sample_points( int $w, int $h ): array {
		$points = [];
		foreach ( [ [ 0.1, 0.1 ], [ 0.9, 0.1 ], [ 0.5, 0.5 ], [ 0.1, 0.9 ], [ 0.9, 0.9 ] ] as $rel ) {
			$points[] = [ (int) round( ( $w - 1 ) * $rel[0] ), (int) round( ( $h - 1 ) * $rel[1] ) ];
		}
		return $points;
	}

	/**
	 * The single colour a GD image decodes to at every sample point, or null
	 * when the samples differ (i.e. the image is not flat).
	 *
	 * @param \GdImage|resource $im GD image.
	 * @param int               $w  Width.
	 * @param int               $h  Height.
	 * @return int|null
	 */
	private function gd_flat_colour( $im, int $w, int $h ): ?int {
		$colour = null;
		foreach ( self::sample_points( $w, $h ) as $p ) {
			$c = @imagecolorat( $im, $p[0], $p[1] );
			if ( false === $c ) {
				return null; // Unreadable sample — treat as not flat (do not reject).
			}
			if ( null === $colour ) {
				$colour = (int) $c;
			} elseif ( (int) $c !== $colour ) {
				return null;
			}
		}
		return $colour;
	}

	/**
	 * Whether the ORIGINAL image is itself one flat colour (GD path). Unknown /
	 * undecodable / out-of-memory sources count as flat so a legitimate flat
	 * conversion is never rejected — only a PROVEN mismatch fails validation.
	 *
	 * @param string $source_path Original JPEG/PNG.
	 * @return bool
	 */
	private function source_is_flat_gd( string $source_path ): bool {
		try {
			$info = @getimagesize( $source_path );
			if ( ! is_array( $info ) || empty( $info[0] ) || empty( $info[1] ) ) {
				return true;
			}
			$this->assert_gd_memory( (int) $info[0], (int) $info[1], (int) ( $info[2] ?? 0 ) );

			if ( IMAGETYPE_JPEG === (int) ( $info[2] ?? 0 ) && function_exists( 'imagecreatefromjpeg' ) ) {
				$im = @imagecreatefromjpeg( $source_path );
			} elseif ( IMAGETYPE_PNG === (int) ( $info[2] ?? 0 ) && function_exists( 'imagecreatefrompng' ) ) {
				$im = @imagecreatefrompng( $source_path );
			} else {
				return true;
			}
			if ( ! $im ) {
				return true;
			}

			$flat = null !== $this->gd_flat_colour( $im, (int) $info[0], (int) $info[1] );
			if ( function_exists( 'imagedestroy' ) ) {
				@imagedestroy( $im );
			}
			return $flat;
		} catch ( \Throwable $e ) {
			return true;
		}
	}

	/**
	 * The single colour an Imagick image holds at every sample point, or null
	 * when the samples differ.
	 *
	 * @param \Imagick $im Imagick image (already read).
	 * @param int      $w  Width.
	 * @param int      $h  Height.
	 * @return string|null Serialized colour, or null when not flat/unreadable.
	 */
	private function imagick_flat_colour( \Imagick $im, int $w, int $h ): ?string {
		try {
			$colour = null;
			foreach ( self::sample_points( $w, $h ) as $p ) {
				$c = (string) wp_json_encode( $im->getImagePixelColor( $p[0], $p[1] )->getColor() );
				if ( null === $colour ) {
					$colour = $c;
				} elseif ( $c !== $colour ) {
					return null;
				}
			}
			return $colour;
		} catch ( \Throwable $e ) {
			return null; // Unreadable sample — treat as not flat (do not reject).
		}
	}

	/**
	 * Whether the ORIGINAL image is itself one flat colour (Imagick path).
	 * {@see source_is_flat_gd()} for the fail-open rationale.
	 *
	 * @param string $source_path Original JPEG/PNG.
	 * @return bool
	 */
	private function source_is_flat_imagick( string $source_path ): bool {
		try {
			$im = new \Imagick();
			try {
				$im->readImage( $source_path );
				return null !== $this->imagick_flat_colour( $im, (int) $im->getImageWidth(), (int) $im->getImageHeight() );
			} finally {
				$im->clear();
			}
		} catch ( \Throwable $e ) {
			return true;
		}
	}

	/**
	 * Whether a WebP file is animated (VP8X ANIM flag). Animated WebP is always
	 * a client upload — we never generate animation — so the repair sweep must
	 * never delete one just because a still-image decoder refuses it.
	 *
	 * @param string $path Absolute path.
	 * @return bool
	 */
	private static function webp_is_animated( string $path ): bool {
		$fh = @fopen( $path, 'rb' );
		if ( ! $fh ) {
			return false;
		}
		$head = (string) fread( $fh, 21 );
		fclose( $fh );
		return strlen( $head ) >= 21
			&& 'RIFF' === substr( $head, 0, 4 )
			&& 'WEBPVP8X' === substr( $head, 8, 8 )
			&& ( ord( $head[20] ) & 0x02 ); // VP8X flags: bit 1 = ANIM.
	}

	/**
	 * Configured quality (clamped to a sane 40–100 range, same for every engine).
	 *
	 * @return int
	 */
	private function quality(): int {
		$q = (int) BCBOOST_get_option( 'compression_quality', 82 );
		return max( 40, min( 100, $q ) );
	}

	/**
	 * Wall-clock budget for a single batch/pass: ~75% of max_execution_time,
	 * capped at 20s (and used as-is when the limit is 0/unlimited) so shared
	 * hosts never hard-kill a batch mid-image.
	 *
	 * @return float Seconds.
	 */
	public function time_budget(): float {
		$max = (int) ini_get( 'max_execution_time' );
		if ( $max <= 0 ) {
			return self::BATCH_TIME_CAP; // Unlimited (CLI etc.) — still keep batches responsive.
		}
		return (float) min( self::BATCH_TIME_CAP, max( 5.0, $max * 0.75 ) );
	}

	// ─── Lifetime stats ─────────────────────────────────────────────────────────

	/**
	 * Add to the running compression totals.
	 *
	 * @param int $images      Number of modern-format files generated.
	 * @param int $bytes_saved Bytes saved.
	 * @return void
	 */
	private function record_stats( int $images, int $bytes_saved ): void {
		$stats = (array) get_option( self::STATS_KEY, [ 'images' => 0, 'bytes_saved' => 0 ] );
		$stats['images']      = (int) ( $stats['images'] ?? 0 ) + $images;
		$stats['bytes_saved'] = (int) ( $stats['bytes_saved'] ?? 0 ) + $bytes_saved;
		update_option( self::STATS_KEY, $stats, false );
	}

	/**
	 * Get lifetime compression stats.
	 *
	 * @return array{images:int, bytes_saved:int}
	 */
	public function get_stats(): array {
		$stats = (array) get_option( self::STATS_KEY, [] );
		return [
			'images'      => (int) ( $stats['images'] ?? 0 ),
			'bytes_saved' => (int) ( $stats['bytes_saved'] ?? 0 ),
		];
	}

	// ─── Bulk compression (client-driven batches) ────────────────────────────────

	/**
	 * How many JPEG/PNG attachments exist (bulk/CLI progress denominators).
	 *
	 * @return int
	 */
	public function count_source_images(): int {
		$counts = (array) wp_count_attachments();
		return (int) ( $counts['image/jpeg'] ?? 0 ) + (int) ( $counts['image/png'] ?? 0 );
	}

	/**
	 * The next JPEG/PNG attachment IDs after a cursor (shared by bulk + CLI).
	 *
	 * @param int $last_id Cursor: only IDs greater than this are returned.
	 * @param int $limit   Maximum IDs to return.
	 * @return int[]
	 */
	public function get_image_ids_after( int $last_id, int $limit ): array {
		global $wpdb;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name only.
		$ids = $wpdb->get_col( $wpdb->prepare(
			"SELECT ID FROM {$wpdb->posts}
			 WHERE post_type = 'attachment'
			   AND post_status = 'inherit'
			   AND post_mime_type IN ( 'image/jpeg', 'image/png' )
			   AND ID > %d
			 ORDER BY ID ASC
			 LIMIT %d",
			$last_id,
			max( 1, $limit )
		) );

		return array_map( 'intval', (array) $ids );
	}

	/**
	 * What a conversion pass would generate for an attachment (dry-run support).
	 *
	 * @param int        $attachment_id Attachment ID.
	 * @param array|null $metadata      Optional pre-loaded metadata.
	 * @return array<int,array{file:string,format:string}>
	 */
	public function pending_conversions( int $attachment_id, ?array $metadata = null ): array {
		$pending = [];

		$mime = (string) get_post_mime_type( $attachment_id );
		if ( ! in_array( $mime, self::SOURCE_MIMES, true ) ) {
			return $pending;
		}

		$formats = $this->target_formats();
		foreach ( $this->collect_attachment_files( $attachment_id, $metadata ) as $file ) {
			foreach ( $formats as $format ) {
				if ( $this->needs_conversion( $file, $format ) ) {
					$pending[] = [ 'file' => $file, 'format' => $format ];
				}
			}
		}

		return $pending;
	}

	/**
	 * Whether a source file lacks a fresh modern-format sibling.
	 *
	 * @param string $source_path Absolute path to a JPEG/PNG file.
	 * @param string $format      'webp' or 'avif'.
	 * @return bool
	 */
	private function needs_conversion( string $source_path, string $format ): bool {
		if ( ! file_exists( $source_path ) || ! preg_match( '/\.(jpe?g|png)$/i', $source_path ) ) {
			return false;
		}
		$dest = (string) preg_replace( '/\.(jpe?g|png)$/i', '.' . $format, $source_path );
		if ( '' === $dest || $dest === $source_path ) {
			return false;
		}
		if ( ! file_exists( $dest ) ) {
			return true;
		}
		// A foreign same-name upload (dimensions differ from the source) is not
		// ours to regenerate — mirror the no-clobber guard in
		// generate_modern_for_file() so pending counts match real behaviour.
		if ( ! Image_Optimizer::dims_match( $source_path, $dest ) ) {
			return false;
		}
		// Our conversion but truncated (worker killed mid-write on a pre-atomic
		// build): needs regenerating no matter how fresh its mtime looks.
		if ( Image_Optimizer::looks_truncated( $dest ) ) {
			return true;
		}
		return filemtime( $dest ) < filemtime( $source_path );
	}

	/**
	 * Begin a bulk run. Uses an ID cursor instead of snapshotting every ID, so it
	 * scales to media libraries of any size without bloating the options table.
	 *
	 * @return array Progress payload.
	 */
	public function start_bulk(): array {
		$total = $this->count_source_images();

		$progress = [
			'status'        => 0 === $total ? 'done' : 'running',
			'last_id'       => 0,
			'total'         => $total,
			'done'          => 0,
			'generated'     => 0,
			'bytes_saved'   => 0,
			'failed'        => 0,
			'failures'      => [],
			'batch_size'    => 0, // 0 = use the caller's default on the first batch.
			'processing_id' => 0,
			'started'       => time(),
		];

		update_option( self::PROGRESS_KEY, $progress, false );
		return $this->public_progress( $progress );
	}

	/**
	 * Process the next batch of attachments (those with ID greater than the cursor).
	 *
	 * Resilient by design:
	 * - Per-image failures are caught and reported, never fataled.
	 * - A wall-clock guard stops the batch early (~75% of max_execution_time,
	 *   20s cap) and returns partial progress so the client loop continues.
	 * - The batch size adapts: halved after a timeout/crash, recovered gradually
	 *   (+1 per clean full batch) up to the caller's maximum.
	 * - A "poison pill" guard skips an image that hard-crashed a previous request
	 *   (memory/time fatal), so one bad file can never wedge the whole run.
	 *
	 * @param int $max_batch_size Upper bound on how many attachments to handle this call.
	 * @return array Progress payload.
	 */
	public function process_bulk_batch( int $max_batch_size = 5 ): array {
		$progress = (array) get_option( self::PROGRESS_KEY, [] );
		if ( empty( $progress ) || ( $progress['status'] ?? '' ) !== 'running' ) {
			return $this->public_progress( $progress ?: [ 'status' => 'idle' ] );
		}

		if ( function_exists( 'wp_raise_memory_limit' ) ) {
			wp_raise_memory_limit( 'image' );
		}

		$max_batch_size = max( 1, $max_batch_size );
		$batch_size     = (int) ( $progress['batch_size'] ?? 0 );
		if ( $batch_size < 1 ) {
			$batch_size = $max_batch_size;
		}
		$batch_size = min( $batch_size, $max_batch_size );

		$deadline   = microtime( true ) + $this->time_budget();
		$last_id    = (int) ( $progress['last_id'] ?? 0 );
		$ids        = $this->get_image_ids_after( $last_id, $batch_size );
		$fetched    = count( $ids );
		$poison     = (int) ( $progress['processing_id'] ?? 0 );
		$timed_out  = false;
		$poison_hit = false;
		$processed  = 0;

		foreach ( $ids as $attachment_id ) {
			// A previous request died hard (memory/time fatal) while on this ID —
			// record it as failed and move on, so one image can't wedge the run.
			if ( $poison && $poison === $attachment_id ) {
				$poison_hit = true;
				$file       = (string) get_attached_file( $attachment_id );
				$this->add_failure(
					$progress,
					$file ? basename( $file ) : 'attachment #' . $attachment_id,
					__( 'the previous attempt crashed on this image (server memory/time limit) — skipped', 'basecloud-boost' )
				);
				$progress['processing_id'] = 0;
				$progress['done']          = (int) ( $progress['done'] ?? 0 ) + 1;
				$progress['last_id']       = $attachment_id;
				$processed++;
				update_option( self::PROGRESS_KEY, $progress, false );
				continue;
			}

			// Mark before processing so a hard fatal is detectable on the next request.
			$progress['processing_id'] = $attachment_id;
			update_option( self::PROGRESS_KEY, $progress, false );

			$res = $this->compress_attachment( $attachment_id, null, $deadline );

			$progress['generated']     = (int) ( $progress['generated'] ?? 0 ) + $res['generated'];
			$progress['bytes_saved']   = (int) ( $progress['bytes_saved'] ?? 0 ) + $res['bytes_saved'];
			$progress['processing_id'] = 0;
			foreach ( $res['failures'] as $failure ) {
				$this->add_failure( $progress, (string) ( $failure['file'] ?? '?' ), (string) ( $failure['reason'] ?? 'unknown error' ) );
			}

			if ( ! empty( $res['timed_out'] ) ) {
				// Out of time mid-attachment. Do NOT advance the cursor: the next
				// batch resumes this same attachment, and its already-finished
				// files are skipped as fresh — so progress is never lost.
				$timed_out = true;
				break;
			}

			$progress['done']    = (int) ( $progress['done'] ?? 0 ) + 1;
			$progress['last_id'] = $attachment_id;
			$processed++;

			if ( microtime( true ) >= $deadline && $processed < $fetched ) {
				$timed_out = true;
				break;
			}
		}

		// Adaptive batch size: shrink after a timeout/crash, recover gradually
		// (+1 per clean full batch) back toward the maximum.
		if ( $timed_out || $poison_hit ) {
			$progress['batch_size'] = max( 1, intdiv( $batch_size, 2 ) );
		} elseif ( $fetched === $batch_size && $batch_size < $max_batch_size ) {
			$progress['batch_size'] = $batch_size + 1;
		} else {
			$progress['batch_size'] = $batch_size;
		}

		// Fewer rows than requested — and no early stop — means we reached the end.
		if ( ! $timed_out && $fetched < $batch_size ) {
			$progress['status']        = 'done';
			$progress['processing_id'] = 0;
			// Keep the reported total honest if the count drifted during the run.
			if ( (int) ( $progress['done'] ?? 0 ) > (int) ( $progress['total'] ?? 0 ) ) {
				$progress['total'] = (int) $progress['done'];
			}
		}

		update_option( self::PROGRESS_KEY, $progress, false );
		return $this->public_progress( $progress );
	}

	/**
	 * Record a per-image failure in the bulk progress (bounded list).
	 *
	 * @param array  $progress Progress array (modified in place).
	 * @param string $file     File name shown to the user.
	 * @param string $reason   Human-readable reason.
	 * @return void
	 */
	private function add_failure( array &$progress, string $file, string $reason ): void {
		$progress['failed'] = (int) ( $progress['failed'] ?? 0 ) + 1;

		$failures   = (array) ( $progress['failures'] ?? [] );
		$failures[] = [ 'file' => $file, 'reason' => $reason ];
		if ( count( $failures ) > self::MAX_FAILURES ) {
			$failures = array_slice( $failures, -self::MAX_FAILURES );
		}
		$progress['failures'] = $failures;
	}

	/**
	 * Stop / reset an in-progress bulk run.
	 *
	 * @return array Progress payload.
	 */
	public function stop_bulk(): array {
		$progress                  = (array) get_option( self::PROGRESS_KEY, [] );
		$progress['status']        = 'idle';
		$progress['processing_id'] = 0;
		update_option( self::PROGRESS_KEY, $progress, false );
		return $this->public_progress( $progress );
	}

	/**
	 * Current bulk progress.
	 *
	 * @return array Progress payload.
	 */
	public function get_bulk_progress(): array {
		return $this->public_progress( (array) get_option( self::PROGRESS_KEY, [ 'status' => 'idle' ] ) );
	}

	/**
	 * Shape internal progress for the browser.
	 *
	 * @param array $progress Raw progress.
	 * @return array
	 */
	private function public_progress( array $progress ): array {
		$total = (int) ( $progress['total'] ?? 0 );
		$done  = (int) ( $progress['done'] ?? 0 );

		return [
			'status'      => (string) ( $progress['status'] ?? 'idle' ),
			'total'       => $total,
			'done'        => $done,
			'generated'   => (int) ( $progress['generated'] ?? 0 ),
			'bytes_saved' => (int) ( $progress['bytes_saved'] ?? 0 ),
			'failed'      => (int) ( $progress['failed'] ?? 0 ),
			'failures'    => array_values( (array) ( $progress['failures'] ?? [] ) ),
			'batch_size'  => (int) ( $progress['batch_size'] ?? 0 ),
			'percentage'  => $total > 0 ? min( 100, (int) floor( $done / $total * 100 ) ) : 100,
		];
	}

	// ─── Verify / repair existing conversions ────────────────────────────────────

	/**
	 * Verify every existing .webp/.avif sibling of one attachment's files and
	 * optionally repair the broken ones. Powers `wp bcboost verify-conversions`.
	 *
	 * Verdicts per sibling:
	 * - ok           — passes structural + decode validation.
	 * - foreign      — dimensions differ from the source: a client's own
	 *                  same-basename upload (quatro guard) — never touched.
	 * - animated     — animated WebP is always client media — never touched.
	 * - unverifiable — no decoder for this format on this host (or the original
	 *                  is unreadable) and structure looks sane — left alone.
	 * - broken       — provably truncated or fails a real decode. Deleted when
	 *                  `$delete_broken` or `$regenerate` is set; `$regenerate`
	 *                  then rebuilds it through the atomic, validated pipeline.
	 *
	 * Also sweeps orphaned `*.tmp` atomic-write litter older than an hour.
	 *
	 * @param int  $attachment_id Attachment ID.
	 * @param bool $delete_broken Delete siblings that fail validation.
	 * @param bool $regenerate    Delete AND regenerate broken siblings.
	 * @return array{checked:int, ok:int, foreign:int, animated:int, unverifiable:int, deleted:int, regenerated:int, broken:string[], regen_failed:string[], tmp_removed:int}
	 */
	public function verify_attachment( int $attachment_id, bool $delete_broken = false, bool $regenerate = false ): array {
		$out = [
			'checked'      => 0,
			'ok'           => 0,
			'foreign'      => 0,
			'animated'     => 0,
			'unverifiable' => 0,
			'deleted'      => 0,
			'regenerated'  => 0,
			'broken'       => [],
			'regen_failed' => [],
			'tmp_removed'  => 0,
		];

		$mime = (string) get_post_mime_type( $attachment_id );
		if ( ! in_array( $mime, self::SOURCE_MIMES, true ) ) {
			return $out;
		}

		foreach ( $this->collect_attachment_files( $attachment_id ) as $file ) {
			foreach ( array_keys( self::FORMAT_MIMES ) as $format ) {
				$dest = (string) preg_replace( '/\.(jpe?g|png)$/i', '.' . $format, $file );
				if ( '' === $dest || $dest === $file ) {
					continue;
				}

				// Sweep stale atomic-write temps (a worker killed mid-write now
				// leaves `.tmp` litter instead of a truncated servable file).
				foreach ( (array) glob( $dest . '.*.tmp' ) as $tmp ) {
					if ( is_string( $tmp ) && (int) @filemtime( $tmp ) < time() - HOUR_IN_SECONDS && @unlink( $tmp ) ) {
						$out['tmp_removed']++;
					}
				}

				if ( ! file_exists( $dest ) ) {
					continue;
				}
				$out['checked']++;

				$verdict = $this->sibling_verdict( $file, $dest, $format );
				if ( 'broken' !== $verdict ) {
					$out[ $verdict ]++;
					continue;
				}

				$out['broken'][] = basename( $dest );
				if ( $delete_broken || $regenerate ) {
					if ( @unlink( $dest ) ) {
						$out['deleted']++;
						Image_Optimizer::forget_file( $dest );
					}
				}
				if ( $regenerate ) {
					try {
						$this->generate_modern_for_file( $file, $format );
						if ( file_exists( $dest ) ) {
							$out['regenerated']++;
						} else {
							$out['regen_failed'][] = basename( $dest ) . ' — regeneration skipped (result not smaller than the original, no capable encoder, or AVIF validation unavailable on this host)';
						}
					} catch ( \Throwable $e ) {
						$out['regen_failed'][] = basename( $dest ) . ' — ' . $e->getMessage();
					}
				}
			}
		}

		return $out;
	}

	/**
	 * Classify one existing modern-format sibling. {@see verify_attachment()}.
	 *
	 * @param string $source Absolute path of the original JPEG/PNG.
	 * @param string $dest   Absolute path of the .webp/.avif sibling.
	 * @param string $format 'webp' or 'avif'.
	 * @return string 'ok' | 'foreign' | 'animated' | 'unverifiable' | 'broken'
	 */
	private function sibling_verdict( string $source, string $dest, string $format ): string {
		$src    = @getimagesize( $source );
		$mod    = @getimagesize( $dest );
		$src_ok = is_array( $src ) && ! empty( $src[0] ) && ! empty( $src[1] );
		$mod_ok = is_array( $mod ) && ! empty( $mod[0] ) && ! empty( $mod[1] );

		// Different dimensions = a client's own upload sharing the basename.
		if ( $src_ok && $mod_ok && ( (int) $src[0] !== (int) $mod[0] || (int) $src[1] !== (int) $mod[1] ) ) {
			return 'foreign';
		}
		// We never generate animation — this is client media whatever its dims.
		if ( 'webp' === $format && self::webp_is_animated( $dest ) ) {
			return 'animated';
		}
		// Structural truncation is proof of breakage on any host.
		if ( Image_Optimizer::looks_truncated( $dest ) ) {
			return 'broken';
		}
		// Beyond structure we need a real decoder and a readable original.
		if ( ! self::can_validate( $format ) || ! $src_ok ) {
			return 'unverifiable';
		}
		return $this->validate_modern_file( $dest, $format, $source ) ? 'ok' : 'broken';
	}

	/**
	 * Log under WP_DEBUG_LOG only — no silent failures, no log spam.
	 *
	 * @param string $message Message.
	 * @return void
	 */
	private static function log( string $message ): void {
		if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
			error_log( '[BaseCloud Boost] Image Compressor: ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		}
	}

	/**
	 * Keys already logged this request (one line per condition, no spam).
	 *
	 * @var array<string, bool>
	 */
	private static array $logged_once = [];

	/**
	 * Log a condition at most once per request.
	 *
	 * @param string $key     Deduplication key.
	 * @param string $message Message.
	 * @return void
	 */
	private static function log_once( string $key, string $message ): void {
		if ( isset( self::$logged_once[ $key ] ) ) {
			return;
		}
		self::$logged_once[ $key ] = true;
		self::log( $message );
	}
}
