<?php
/**
 * AVIF Conversion Module
 *
 * Creates AVIF copies of uploaded JPEG/PNG (default metadata priority 8; leaves room for resize modules at 5–7). Conflicts with WebP Conversion.
 *
 * @package AvifConversion
 */

namespace AvifConversion;

use WP_PowerSuite\Core\AbstractModule;

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

class Module extends AbstractModule {

	public const MODULE_ID = 'avif-conversion';

	private const MODULE_VERSION = '1.0.0';

	private const META_REL_PATH = '_wpps_avif_rel_path';

	private const META_SIZE_REL_PATHS = '_wpps_avif_size_rel_paths';

	private const SUMMARY_TRANSIENT = 'wp_powersuite_avif_library_summary_v1';

	private const RESTORABLE_COUNT_TRANSIENT = 'wp_powersuite_avif_restorable_count_v1';

	public const RESTORE_NONCE_ACTION = 'wp_powersuite_avif_restore';

	private const RESTORE_QUEUE_TRANSIENT_PREFIX = 'wpps_avif_restore_ids_';

	/**
	 * Suppress AVIF encoding inside wp_generate_attachment_metadata (restore calls regeneration; otherwise we would re-convert immediately).
	 */
	private static bool $suppress_conversion_during_metadata_regeneration = false;

	/** @var array<int, bool> Per-request cache for attachmentHasUsableAvif(). */
	private static array $usable_avif_cache = [];

	/** Buffered conversion stats flushed once per request on shutdown. */
	private static array $pending_stats = [
		'images' => 0,
		'bytes'  => 0,
		'log'    => [],
	];

	private static bool $stats_shutdown_registered = false;

	/** Markup flag so wp_content_img_tag does not wrap the same img twice. */
	private const IMG_DATA_AVIF_PICTURE = 'data-wpps-avif-picture="1"';

	private const CONVERTIBLE_EXTENSIONS = [ 'jpg', 'jpeg', 'png' ];

	private const CRON_HOOK = 'wp_powersuite_avif_process_attachment';

	/** Upper bound on attachments scanned for the cached library summary (prevents unbounded queries). */
	private const MAX_SCAN = 5000;

	private const DEFAULT_EXCLUSION_SUFFIX = '-wpps';

	private ?Avif_Engine $engine = null;

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'AVIF Conversion', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Adds smaller AVIF copies for JPEG and PNG uploads so pages can load faster. Optional setting to show those images to modern browsers. Do not use together with WebP Conversion.', 'wp-powersuite' );
	}

	/**
	 * Frontend context is gated at load time: the module boots on the frontend only when
	 * serve_avif_picture is enabled (see ModuleManager::shouldLoadModule).
	 */
	public function getContext(): string|array {
		return [ 'admin', 'ajax', 'rest', 'frontend', 'cron' ];
	}

	public function getDependencies(): array {
		return [];
	}

	private function isLicenseActive(): bool {
		return function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();
	}

	protected function registerHooks(): void {
		if ( ! $this->isLicenseActive() ) {
			return;
		}

		$settings = $this->getSettings();

		if ( is_admin() ) {
			add_action( 'admin_notices', [ $this, 'maybeAdminNotice' ] );
			add_action( 'wp_ajax_wp_powersuite_avif_restore_batch', [ $this, 'ajaxRestoreBatch' ] );
		}

		// Frontend delivery: does not require a working encoder (files may already exist on disk).
		if ( ! is_admin() && ! empty( $settings['serve_avif_picture'] ) ) {
			add_filter( 'wp_get_attachment_image', [ $this, 'filterAttachmentImageHtml' ], 10, 5 );
			add_filter( 'wp_content_img_tag', [ $this, 'filterContentImgTag' ], 10, 3 );
		}

		if ( ! $this->needsConversionHooks( $settings ) ) {
			return;
		}

		add_action( self::CRON_HOOK, [ $this, 'cronProcessAttachment' ], 10, 1 );

		// AVIF MIME needed when the attachment file becomes AVIF (replace-original). Sidecar mode keeps JPEG/PNG.
		if ( ! empty( $settings['replace_original'] ) ) {
			add_filter( 'upload_mimes', [ $this, 'ensureAvifMime' ] );
		}

		/**
		 * Encoder loads inside the callback on upload only (lazy ensureEngine).
		 *
		 * Run after typical in-metadata resize modules (often priority 5–7) and before WebP Conversion.
		 * Default 8 leaves room for resize-at-5; WebP uses 9 so JPEG/PNG still exist for sidecar AVIF.
		 *
		 * @since 1.0.0
		 */
		$priority = (int) apply_filters( 'wp_powersuite_avif_metadata_priority', 8 );
		$priority = max( 0, min( 999, $priority ) );
		add_filter( 'wp_generate_attachment_metadata', [ $this, 'onGenerateAttachmentMetadata' ], $priority, 2 );
	}

	/**
	 * Whether upload/cron conversion hooks should register (skip pure frontend when only serving pictures).
	 *
	 * @param array<string, mixed> $settings
	 */
	private function needsConversionHooks( array $settings ): bool {
		if ( empty( $settings['convert_jpeg'] ) && empty( $settings['convert_png'] ) ) {
			return false;
		}

		if ( wp_doing_cron() ) {
			return true;
		}

		if ( wp_doing_ajax() ) {
			return true;
		}

		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return true;
		}

		return is_admin();
	}


	/**
	 * Load encoder and refresh binary settings (CLI / cron safe).
	 */
	public function ensureEngine(): bool {
		if ( $this->engine !== null ) {
			return $this->engine->isSupported();
		}
		require_once __DIR__ . '/includes/class-avif-engine.php';
		$settings     = $this->getSettings();
		$this->engine = new Avif_Engine(
			(string) ( $settings['external_binary_path'] ?? '' ),
			! empty( $settings['allow_external_binary'] )
		);
		return $this->engine->isSupported();
	}

	/**
	 * True when this attachment already has a usable AVIF (replaced file or existing sidecar).
	 */
	private function attachmentHasUsableAvif( int $attachment_id ): bool {
		if ( isset( self::$usable_avif_cache[ $attachment_id ] ) ) {
			return self::$usable_avif_cache[ $attachment_id ];
		}

		$result = $this->computeAttachmentHasUsableAvif( $attachment_id );
		self::$usable_avif_cache[ $attachment_id ] = $result;

		return $result;
	}

	private function computeAttachmentHasUsableAvif( int $attachment_id ): bool {
		if ( get_post_mime_type( $attachment_id ) === 'image/avif' ) {
			$file = get_attached_file( $attachment_id, true );
			return is_string( $file ) && $this->isAllowedUploadPath( $file ) && is_file( $file );
		}
		$existing = get_post_meta( $attachment_id, self::META_REL_PATH, true );
		if ( is_string( $existing ) && $existing !== '' && $this->getUploadPathFromRelativeAvif( $existing ) !== '' ) {
			return true;
		}
		foreach ( $this->getStoredSizeRelPaths( $attachment_id ) as $rel ) {
			if ( $this->getUploadPathFromRelativeAvif( $rel ) !== '' ) {
				return true;
			}
		}
		$file = get_attached_file( $attachment_id, true );
		if ( ! is_string( $file ) || ! is_file( $file ) ) {
			return false;
		}
		$info = pathinfo( $file );
		$ext  = isset( $info['extension'] ) ? strtolower( (string) $info['extension'] ) : '';
		if ( ! in_array( $ext, self::CONVERTIBLE_EXTENSIONS, true ) ) {
			return false;
		}
		$avif = ( $info['dirname'] ?? '' ) . DIRECTORY_SEPARATOR . ( $info['filename'] ?? '' ) . '.avif';
		return $avif !== $file && $this->isAllowedUploadPath( $avif ) && is_file( $avif );
	}

	/**
	 * Find the next restorable attachment ID after $cursor (no full-ID list in memory).
	 */
	private function findNextRestorableAttachmentId( int $cursor ): int {
		global $wpdb;

		$search_cursor = $cursor;
		$attempts      = 0;
		while ( $attempts < 200 ) {
			$id = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT ID FROM {$wpdb->posts}
					WHERE post_type = 'attachment'
					AND post_status = 'inherit'
					AND ID > %d
					ORDER BY ID ASC
					LIMIT 1",
					$search_cursor
				)
			);
			if ( $id <= 0 ) {
				return 0;
			}
			if ( $this->attachmentIsRestorable( $id ) ) {
				return $id;
			}
			$search_cursor = $id;
			++$attempts;
		}

		return 0;
	}

	/**
	 * Current library totals (cached attachment scan — not cumulative history).
	 *
	 * @return array{count: int, bytes_saved: int}
	 */
	public function computeLibraryAvifSummary(): array {
		$cached = get_transient( self::SUMMARY_TRANSIENT );
		if ( is_array( $cached ) && isset( $cached['count'], $cached['bytes_saved'] ) ) {
			return [
				'count'       => max( 0, (int) $cached['count'] ),
				'bytes_saved' => max( 0, (int) $cached['bytes_saved'] ),
			];
		}

		$args = (array) apply_filters(
			'wp_powersuite_avif_summary_query_args',
			[
				'post_type'              => 'attachment',
				'post_status'            => 'inherit',
				'post_mime_type'         => [ 'image/jpeg', 'image/png', 'image/avif' ],
				'fields'                 => 'ids',
				'posts_per_page'         => self::MAX_SCAN,
				'orderby'                => 'ID',
				'order'                  => 'ASC',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
			]
		);
		$ids = get_posts( $args );
		if ( ! is_array( $ids ) ) {
			return [ 'count' => 0, 'bytes_saved' => 0 ];
		}
		$count = 0;
		$bytes = 0;
		foreach ( $ids as $aid ) {
			$aid = (int) $aid;
			if ( $aid <= 0 || ! $this->attachmentHasUsableAvif( $aid ) ) {
				continue;
			}
			++$count;
			$bytes += $this->estimateBytesSavedForAttachment( $aid );
		}
		$summary = [ 'count' => $count, 'bytes_saved' => $bytes ];
		set_transient( self::SUMMARY_TRANSIENT, $summary, 15 * MINUTE_IN_SECONDS );
		return $summary;
	}

	/**
	 * Count attachments that can be restored (remove AVIF sidecars and/or bring back JPEG/PNG from originals/).
	 *
	 * Result cached briefly; cleared when conversions or restores change files.
	 */
	public function countRestorableAttachments(): int {
		$cached = get_transient( self::RESTORABLE_COUNT_TRANSIENT );
		if ( false !== $cached ) {
			return max( 0, (int) $cached );
		}
		$n = count( $this->getRestorableAttachmentIds() );
		set_transient( self::RESTORABLE_COUNT_TRANSIENT, $n, 15 * MINUTE_IN_SECONDS );
		return $n;
	}

	/**
	 * @return int[]
	 */
	public function getRestorableAttachmentIds(): array {
		$candidates = $this->getCandidateRestoreAttachmentIds();
		$out        = [];
		foreach ( $candidates as $id ) {
			$id = (int) $id;
			if ( $id > 0 && $this->attachmentIsRestorable( $id ) ) {
				$out[] = $id;
			}
		}
		return array_values( array_unique( $out ) );
	}

	/**
	 * @return int[]
	 */
	private function getCandidateRestoreAttachmentIds(): array {
		global $wpdb;

		$from_meta = $wpdb->get_col(
			"SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key IN ( '_wpps_avif_rel_path', '_wpps_avif_size_rel_paths' )"
		);
		$from_meta = is_array( $from_meta ) ? array_map( 'intval', $from_meta ) : [];

		$mime_ids = get_posts(
			[
				'post_type'              => 'attachment',
				'post_status'            => 'inherit',
				'post_mime_type'         => 'image/avif',
				'fields'                 => 'ids',
				'posts_per_page'         => -1,
				'orderby'                => 'ID',
				'order'                  => 'ASC',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
			]
		);
		$mime_ids = is_array( $mime_ids ) ? array_map( 'intval', $mime_ids ) : [];

		return array_values( array_unique( array_merge( $from_meta, $mime_ids ) ) );
	}

	/**
	 * Whether this attachment has AVIF output we can roll back (backup in originals/, sidecar files, or meta).
	 */
	public function attachmentIsRestorable( int $attachment_id ): bool {
		if ( $attachment_id <= 0 || get_post_type( $attachment_id ) !== 'attachment' ) {
			return false;
		}

		if ( get_post_mime_type( $attachment_id ) === 'image/avif' ) {
			return $this->findOriginalBackupPath( $attachment_id ) !== '';
		}

		$file = get_attached_file( $attachment_id, true );
		if ( ! is_string( $file ) || ! $this->isAllowedUploadPath( $file ) || ! is_file( $file ) ) {
			return false;
		}

		$rel_main = get_post_meta( $attachment_id, self::META_REL_PATH, true );
		if ( is_string( $rel_main ) && $rel_main !== '' && $this->getUploadPathFromRelativeAvif( $rel_main ) !== '' ) {
			return true;
		}

		foreach ( $this->getStoredSizeRelPaths( $attachment_id ) as $rel ) {
			if ( $this->getUploadPathFromRelativeAvif( $rel ) !== '' ) {
				return true;
			}
		}

		$info = pathinfo( $file );
		$ext  = isset( $info['extension'] ) ? strtolower( (string) $info['extension'] ) : '';
		if ( ! in_array( $ext, self::CONVERTIBLE_EXTENSIONS, true ) ) {
			return false;
		}

		$sidecar_avif = ( $info['dirname'] ?? '' ) . DIRECTORY_SEPARATOR . ( $info['filename'] ?? '' ) . '.avif';
		return $sidecar_avif !== $file && $this->isAllowedUploadPath( $sidecar_avif ) && is_file( $sidecar_avif );
	}

	private function findOriginalBackupPath( int $attachment_id ): string {
		$main = get_attached_file( $attachment_id, true );
		if ( ! is_string( $main ) || ! $this->isAllowedUploadPath( $main ) ) {
			return '';
		}
		$stem = pathinfo( $main, PATHINFO_FILENAME );
		$dir  = dirname( $main );
		foreach ( [ 'jpg', 'jpeg', 'png' ] as $e ) {
			$p = $dir . DIRECTORY_SEPARATOR . 'originals' . DIRECTORY_SEPARATOR . $stem . '.' . $e;
			if ( $this->isAllowedUploadPath( $p ) && is_file( $p ) ) {
				return $p;
			}
		}
		return '';
	}

	/**
	 * Restore JPEG/PNG originals and remove plugin-created AVIF files for one attachment.
	 */
	public function restoreAttachment( int $attachment_id ): bool {
		if ( ! $this->attachmentIsRestorable( $attachment_id ) ) {
			return false;
		}

		$this->unscheduleAvifCronForAttachment( $attachment_id );

		if ( get_post_mime_type( $attachment_id ) === 'image/avif' ) {
			return $this->restoreReplacedOriginalAttachment( $attachment_id );
		}

		return $this->restoreSidecarAvifsForAttachment( $attachment_id );
	}

	private function restoreReplacedOriginalAttachment( int $attachment_id ): bool {
		$backup = $this->findOriginalBackupPath( $attachment_id );
		if ( $backup === '' ) {
			return false;
		}

		$main_avif = get_attached_file( $attachment_id, true );
		if ( ! is_string( $main_avif ) || ! $this->isAllowedUploadPath( $main_avif ) || ! is_file( $main_avif ) ) {
			return false;
		}

		$upload_dir = wp_upload_dir();
		if ( ! empty( $upload_dir['error'] ) ) {
			return false;
		}

		$target = dirname( $main_avif ) . DIRECTORY_SEPARATOR . basename( $backup );
		if ( ! $this->isAllowedUploadPath( $target ) ) {
			return false;
		}

		if ( is_file( $target ) && wp_normalize_path( $target ) !== wp_normalize_path( $backup ) ) {
			return false;
		}

		wp_delete_file( $main_avif );

		if ( ! @rename( $backup, $target ) ) {
			if ( ! @copy( $backup, $target ) ) {
				return false;
			}
			wp_delete_file( $backup );
		}

		$new_rel = ltrim( str_replace( wp_normalize_path( $upload_dir['basedir'] ), '', wp_normalize_path( $target ) ), '/' );
		$new_rel = str_replace( '\\', '/', $new_rel );

		delete_post_meta( $attachment_id, self::META_REL_PATH );
		delete_post_meta( $attachment_id, self::META_SIZE_REL_PATHS );

		update_post_meta( $attachment_id, '_wp_attached_file', $new_rel );

		$mime = 'image/jpeg';
		if ( str_ends_with( strtolower( $target ), '.png' ) ) {
			$mime = 'image/png';
		}

		$new_url = $upload_dir['baseurl'] . '/' . $new_rel;
		wp_update_post(
			[
				'ID'             => $attachment_id,
				'guid'           => $new_url,
				'post_mime_type' => $mime,
			]
		);

		if ( function_exists( 'wp_generate_attachment_metadata' ) ) {
			self::$suppress_conversion_during_metadata_regeneration = true;
			try {
				wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $target ) );
			} finally {
				self::$suppress_conversion_during_metadata_regeneration = false;
			}
		}

		return true;
	}

	/**
	 * Remove queued deferred conversion so restore is not overwritten by cron.
	 */
	private function unscheduleAvifCronForAttachment( int $attachment_id ): void {
		if ( $attachment_id <= 0 ) {
			return;
		}
		$args = [ $attachment_id ];
		$ts   = wp_next_scheduled( self::CRON_HOOK, $args );
		while ( false !== $ts ) {
			wp_unschedule_event( $ts, self::CRON_HOOK, $args );
			$ts = wp_next_scheduled( self::CRON_HOOK, $args );
		}
	}

	private function restoreSidecarAvifsForAttachment( int $attachment_id ): bool {
		$rel_main = get_post_meta( $attachment_id, self::META_REL_PATH, true );
		if ( is_string( $rel_main ) && $rel_main !== '' ) {
			$p = $this->getUploadPathFromRelativeAvif( $rel_main );
			if ( $p !== '' ) {
				wp_delete_file( $p );
			}
		}

		foreach ( $this->getStoredSizeRelPaths( $attachment_id ) as $rel ) {
			$p = $this->getUploadPathFromRelativeAvif( $rel );
			if ( $p !== '' ) {
				wp_delete_file( $p );
			}
		}

		$file = get_attached_file( $attachment_id, true );
		if ( is_string( $file ) && $this->isAllowedUploadPath( $file ) ) {
			$info       = pathinfo( $file );
			$sidecar_av = ( $info['dirname'] ?? '' ) . DIRECTORY_SEPARATOR . ( $info['filename'] ?? '' ) . '.avif';
			if ( $sidecar_av !== $file && $this->isAllowedUploadPath( $sidecar_av ) && is_file( $sidecar_av ) ) {
				wp_delete_file( $sidecar_av );
			}
		}

		delete_post_meta( $attachment_id, self::META_REL_PATH );
		delete_post_meta( $attachment_id, self::META_SIZE_REL_PATHS );

		return true;
	}

	/**
	 * Batch restore (AJAX). Processes attachments incrementally by cursor (attachment ID).
	 */
	public function ajaxRestoreBatch(): void {
		if ( ! check_ajax_referer( self::RESTORE_NONCE_ACTION, 'nonce', false ) ) {
			wp_send_json_error(
				[
					'message' => __( 'Security check failed.', 'wp-powersuite' ),
					'code'    => 'bad_nonce',
				],
				403
			);
		}

		if ( ! \WP_PowerSuite\Core\NetworkScope::canManagePlugin() ) {
			wp_send_json_error(
				[
					'message' => __( 'You do not have permission to restore attachments.', 'wp-powersuite' ),
					'code'    => 'forbidden',
				],
				403
			);
		}

		if ( ! $this->isEnabled() ) {
			wp_send_json_error(
				[
					'message' => __( 'AVIF Conversion is disabled.', 'wp-powersuite' ),
					'code'    => 'module_disabled',
				]
			);
		}

		$cursor = isset( $_POST['cursor'] ) ? max( 0, (int) wp_unslash( $_POST['cursor'] ) ) : 0;
		if ( $cursor <= 0 && isset( $_POST['offset'] ) ) {
			$cursor = max( 0, (int) wp_unslash( $_POST['offset'] ) );
		}
		$batch  = isset( $_POST['batch'] ) ? max( 1, min( 40, (int) wp_unslash( $_POST['batch'] ) ) ) : 12;

		$restored       = 0;
		$processed      = 0;
		$search_cursor  = $cursor;
		$last_processed = $cursor;

		while ( $processed < $batch ) {
			$aid = $this->findNextRestorableAttachmentId( $search_cursor );
			if ( $aid <= 0 ) {
				break;
			}

			/**
			 * Before restoring AVIF-related files for an attachment.
			 *
			 * @param int $attachment_id Attachment ID.
			 */
			do_action( 'wp_powersuite_avif_before_restore_attachment', $aid );

			if ( $this->restoreAttachment( $aid ) ) {
				++$restored;
			}

			++$processed;
			$search_cursor  = $aid;
			$last_processed = $aid;
		}

		$done = $processed < $batch;

		if ( $done ) {
			delete_transient( self::RESTORE_QUEUE_TRANSIENT_PREFIX . get_current_user_id() );
		}

		$this->invalidateAvifCaches();

		wp_send_json_success(
			[
				'done'         => $done,
				'total'        => $this->countRestorableAttachments(),
				'processed'    => $processed,
				'restored'     => $restored,
				'next_cursor'  => $last_processed,
				'next_offset'  => $last_processed,
			]
		);
	}

	private function invalidateAvifCaches(): void {
		delete_transient( self::SUMMARY_TRANSIENT );
		delete_transient( self::RESTORABLE_COUNT_TRANSIENT );
	}

	/**
	 * Absolute paths to AVIF files for this attachment (sidecar, sizes, or main AVIF).
	 *
	 * @return string[]
	 */
	private function getResolvedAvifPaths( int $attachment_id ): array {
		$paths = [];

		$rel = get_post_meta( $attachment_id, self::META_REL_PATH, true );
		if ( is_string( $rel ) && $rel !== '' ) {
			$p = $this->getUploadPathFromRelativeAvif( $rel );
			if ( $p !== '' ) {
				$paths[] = $p;
			}
		}

		foreach ( $this->getStoredSizeRelPaths( $attachment_id ) as $rel ) {
			$p = $this->getUploadPathFromRelativeAvif( $rel );
			if ( $p !== '' ) {
				$paths[] = $p;
			}
		}

		if ( get_post_mime_type( $attachment_id ) === 'image/avif' ) {
			$file = get_attached_file( $attachment_id, true );
			if ( is_string( $file ) && $this->isAllowedUploadPath( $file ) && is_file( $file ) ) {
				$paths[] = $file;
			}
		}

		$file = get_attached_file( $attachment_id, true );
		if ( ! is_string( $file ) || ! is_file( $file ) ) {
			return array_values( array_unique( $paths ) );
		}
		$info = pathinfo( $file );
		$ext  = isset( $info['extension'] ) ? strtolower( (string) $info['extension'] ) : '';
		if ( ! in_array( $ext, self::CONVERTIBLE_EXTENSIONS, true ) ) {
			return array_values( array_unique( $paths ) );
		}
		$avif = ( $info['dirname'] ?? '' ) . DIRECTORY_SEPARATOR . ( $info['filename'] ?? '' ) . '.avif';
		if ( $avif !== $file && $this->isAllowedUploadPath( $avif ) && is_file( $avif ) ) {
			$paths[] = $avif;
		}

		return array_values( array_unique( $paths ) );
	}

	/**
	 * Approximate bytes smaller than the source (JPEG/PNG or originals/ backup) for one attachment.
	 */
	private function estimateBytesSavedForAttachment( int $attachment_id ): int {
		$total = 0;
		foreach ( $this->getResolvedAvifPaths( $attachment_id ) as $avif_path ) {
			$total += $this->estimateBytesSavedForAvifPath( $attachment_id, $avif_path );
		}
		return $total;
	}

	private function estimateBytesSavedForAvifPath( int $attachment_id, string $avif_path ): int {
		$avif_size = (int) @filesize( $avif_path );
		if ( $avif_size < 1 ) {
			return 0;
		}

		$mime = get_post_mime_type( $attachment_id );
		if ( $mime === 'image/avif' ) {
			$dir  = dirname( $avif_path );
			$stem = pathinfo( $avif_path, PATHINFO_FILENAME );
			foreach ( [ 'jpg', 'jpeg', 'png' ] as $e ) {
				$backup = $dir . DIRECTORY_SEPARATOR . 'originals' . DIRECTORY_SEPARATOR . $stem . '.' . $e;
				if ( is_file( $backup ) ) {
					$o = (int) @filesize( $backup );
					return max( 0, $o - $avif_size );
				}
			}
			return 0;
		}
		$dir  = dirname( $avif_path );
		$stem = pathinfo( $avif_path, PATHINFO_FILENAME );
		foreach ( [ 'jpg', 'jpeg', 'png' ] as $e ) {
			$source = $dir . DIRECTORY_SEPARATOR . $stem . '.' . $e;
			if ( is_file( $source ) ) {
				$source_size = (int) @filesize( $source );
				return max( 0, $source_size - $avif_size );
			}
		}

		$main = get_attached_file( $attachment_id, true );
		if ( ! is_string( $main ) || ! is_file( $main ) ) {
			return 0;
		}
		$main_size = (int) @filesize( $main );
		return max( 0, $main_size - $avif_size );
	}


	/**
	 * @param array<string, string> $mimes
	 * @return array<string, string>
	 */
	public function ensureAvifMime( array $mimes ): array {
		$mimes['avif'] = 'image/avif';
		return $mimes;
	}

	public function maybeAdminNotice(): void {
		if ( ! is_admin() || ! \WP_PowerSuite\Core\NetworkScope::canManagePlugin() ) {
			return;
		}

		if ( ! \WP_PowerSuite\Core\AdminMenu::isPowerSuiteAdminScreen() ) {
			$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
			$on_media = $screen instanceof \WP_Screen && in_array( $screen->id, [ 'upload', 'media' ], true );
			if ( ! $on_media ) {
				return;
			}
		}

		$this->ensureEngine();
		if ( $this->engine === null || $this->engine->isSupported() ) {
			return;
		}
		echo '<div class="notice notice-warning is-dismissible"><p>';
		echo esc_html(
			sprintf(
				/* translators: %s: module name */
				__( '%s is on, but this hosting cannot create AVIF files yet. New uploads will not get AVIF until your host enables it—or you can turn this module off.', 'wp-powersuite' ),
				$this->getName()
			)
		);
		echo '</p></div>';
	}

	/**
	 * @param array<string, mixed> $metadata
	 * @return array<string, mixed>
	 */
	public function onGenerateAttachmentMetadata( array $metadata, int $attachment_id ): array {
		if ( ! $this->isEnabled() ) {
			return $metadata;
		}

		if ( self::$suppress_conversion_during_metadata_regeneration ) {
			return $metadata;
		}

		if ( apply_filters( 'wp_powersuite_skip_avif_conversion', false, $attachment_id, $metadata ) ) {
			return $metadata;
		}

		if ( ! $this->ensureEngine() || $this->engine === null || ! $this->engine->isSupported() ) {
			return $metadata;
		}

		$file = get_attached_file( $attachment_id, true );
		$settings = $this->getSettings();
		if ( ! is_string( $file ) || ! $this->isAttachmentEligibleForConversion( $attachment_id, $file, $settings ) ) {
			return $metadata;
		}

		$path_info = pathinfo( $file );
		$ext       = isset( $path_info['extension'] ) ? strtolower( (string) $path_info['extension'] ) : '';
		$size      = filesize( $file );
		if ( $size === false ) {
			return $metadata;
		}

		$sync_max = (int) ( $settings['sync_max_bytes'] ?? 1048576 );
		if ( $size > $sync_max ) {
			if ( ! wp_next_scheduled( self::CRON_HOOK, [ $attachment_id ] ) ) {
				wp_schedule_single_event( time() + 1, self::CRON_HOOK, [ $attachment_id ] );
			}
			return $metadata;
		}

		return $this->runConversion( $attachment_id, $file, $path_info, $ext, $settings, $metadata );
	}

	/**
	 * @param mixed $attachment_id
	 */
	public function cronProcessAttachment( $attachment_id ): void {
		$attachment_id = (int) $attachment_id;
		if ( $attachment_id <= 0 || get_post_type( $attachment_id ) !== 'attachment' ) {
			return;
		}

		if ( ! $this->isEnabled() || ! $this->ensureEngine() || $this->engine === null || ! $this->engine->isSupported() ) {
			return;
		}

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

		$file = get_attached_file( $attachment_id, true );
		$settings = $this->getSettings();
		if ( ! is_string( $file ) || ! $this->isAttachmentEligibleForConversion( $attachment_id, $file, $settings ) ) {
			return;
		}

		$path_info = pathinfo( $file );
		$ext       = isset( $path_info['extension'] ) ? strtolower( (string) $path_info['extension'] ) : '';
		$metadata         = wp_get_attachment_metadata( $attachment_id );
		$metadata         = is_array( $metadata ) ? $metadata : [];
		$updated_metadata = $this->runConversion( $attachment_id, $file, $path_info, $ext, $settings, $metadata );
		if ( $updated_metadata !== $metadata ) {
			wp_update_attachment_metadata( $attachment_id, $updated_metadata );
		}
	}

	/**
	 * @param array<string, string> $path_info
	 * @param array<string, mixed>  $settings
	 * @param array<string, mixed>  $metadata
	 * @return array<string, mixed>
	 */
	private function runConversion( int $attachment_id, string $file, array $path_info, string $ext, array $settings, array $metadata ): array {
		if ( $this->engine === null ) {
			return $metadata;
		}

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

		$dirname       = $path_info['dirname'] ?? '';
		$filename_base = $path_info['filename'] ?? '';
		if ( $dirname === '' || $filename_base === '' ) {
			return $metadata;
		}

		[ 'quality' => $quality, 'speed' => $speed ] = $this->getEncodingParams( $settings );
		$full_result = $this->convertSourceToAvif( $file, $path_info, $ext, $quality, $speed );

		if ( $full_result !== null ) {
			if ( ! empty( $settings['replace_original'] ) ) {
				$metadata = $this->replaceOriginalWithAvif( $attachment_id, $file, $full_result['path'], $path_info, $full_result['rel'], $settings, $metadata );
			} else {
				update_post_meta( $attachment_id, self::META_REL_PATH, $full_result['rel'] );
			}
			$this->bumpStats( $full_result['saved'], $full_result['rel'] );
		}

		$size_map = [];
		if ( empty( $settings['replace_original'] ) ) {
			$size_map = $this->convertMetadataSizesToAvif( $file, $metadata, $quality, $speed );
			if ( ! empty( $size_map ) ) {
				update_post_meta( $attachment_id, self::META_SIZE_REL_PATHS, $size_map );
			}
		}

		if ( $full_result !== null || ! empty( $size_map ) ) {
			$this->invalidateAvifCaches();
		}
		return $metadata;
	}

	/**
	 * @param array<string, string> $path_info
	 * @return array{path: string, rel: string, saved: int}|null
	 */
	private function convertSourceToAvif( string $file, array $path_info, string $ext, int $quality, int $speed ): ?array {
		if ( $this->engine === null ) {
			return null;
		}

		$dirname       = $path_info['dirname'] ?? '';
		$filename_base = $path_info['filename'] ?? '';
		if ( $dirname === '' || $filename_base === '' ) {
			return null;
		}

		$avif_path = $dirname . DIRECTORY_SEPARATOR . $filename_base . '.avif';
		if ( $avif_path === $file || ! $this->isAllowedUploadPath( $avif_path ) ) {
			return null;
		}

		$source_bytes = @filesize( $file );
		if ( $source_bytes === false ) {
			return null;
		}

		if ( ! $this->engine->convert( $file, $avif_path, $quality, $speed, $ext ) ) {
			if ( is_file( $avif_path ) ) {
				wp_delete_file( $avif_path );
			}
			return null;
		}

		$out_bytes = @filesize( $avif_path );
		if ( ! is_file( $avif_path ) || $out_bytes === false || $out_bytes <= 0 ) {
			return null;
		}

		$saved = (int) $source_bytes - (int) $out_bytes;
		if ( $saved <= 0 ) {
			wp_delete_file( $avif_path );
			return null;
		}

		$upload_dir = wp_upload_dir();
		if ( ! empty( $upload_dir['error'] ) ) {
			wp_delete_file( $avif_path );
			return null;
		}

		$rel = ltrim( str_replace( wp_normalize_path( $upload_dir['basedir'] ), '', wp_normalize_path( $avif_path ) ), '/' );
		$rel = str_replace( '\\', '/', $rel );
		if ( $this->getUploadPathFromRelativeAvif( $rel ) === '' ) {
			wp_delete_file( $avif_path );
			return null;
		}

		return [
			'path'  => $avif_path,
			'rel'   => $rel,
			'saved' => $saved,
		];
	}

	/**
	 * Convert generated image sizes and return a map of size name to AVIF relative path.
	 *
	 * @param array<string, mixed> $metadata
	 * @return array<string, string>
	 */
	private function convertMetadataSizesToAvif( string $main_file, array $metadata, int $quality, int $speed ): array {
		if ( empty( $metadata['sizes'] ) || ! is_array( $metadata['sizes'] ) ) {
			return [];
		}

		$base_dir = dirname( $main_file );
		$map      = [];
		foreach ( $metadata['sizes'] as $size_name => $size_data ) {
			if ( ! is_string( $size_name ) || ! is_array( $size_data ) || empty( $size_data['file'] ) || ! is_string( $size_data['file'] ) ) {
				continue;
			}

			$source = $base_dir . DIRECTORY_SEPARATOR . $size_data['file'];
			if ( ! $this->isAllowedUploadPath( $source ) || ! is_file( $source ) || ! is_readable( $source ) ) {
				continue;
			}

			$info = pathinfo( $source );
			$ext  = isset( $info['extension'] ) ? strtolower( (string) $info['extension'] ) : '';
			if ( ! in_array( $ext, self::CONVERTIBLE_EXTENSIONS, true ) ) {
				continue;
			}

			$result = $this->convertSourceToAvif( $source, $info, $ext, $quality, $speed );
			if ( $result === null ) {
				continue;
			}

			$map[ $size_name ] = $result['rel'];
			$this->bumpStats( $result['saved'], $result['rel'] );
		}

		return $map;
	}

	/**
	 * @param array<string, string> $path_info
	 * @param array<string, mixed>  $settings
	 * @param array<string, mixed>  $metadata
	 * @return array<string, mixed>
	 */
	private function replaceOriginalWithAvif( int $attachment_id, string $original_file, string $avif_path, array $path_info, string $new_rel, array $settings, array $metadata ): array {
		$upload_dir = wp_upload_dir();
		if ( ! empty( $upload_dir['error'] ) ) {
			return $metadata;
		}

		update_post_meta( $attachment_id, self::META_REL_PATH, $new_rel );

		update_post_meta( $attachment_id, '_wp_attached_file', $new_rel );
		$metadata['file'] = $new_rel;

		$new_url = $upload_dir['baseurl'] . '/' . $new_rel;
		wp_update_post(
			[
				'ID'   => $attachment_id,
				'guid' => $new_url,
			]
		);
		wp_update_post(
			[
				'ID'             => $attachment_id,
				'post_mime_type' => 'image/avif',
			]
		);

		if ( ! empty( $settings['keep_original'] ) ) {
			$this->moveOriginalToOriginalsFolder( $original_file, $path_info );
		} else {
			wp_delete_file( $original_file );
		}

		return $metadata;
	}

	/**
	 * @param array<string, string> $path_info
	 */
	private function moveOriginalToOriginalsFolder( string $file, array $path_info ): void {
		$dir      = $path_info['dirname'] ?? '';
		$basename = $path_info['basename'] ?? '';
		if ( $dir === '' || $basename === '' || ! is_file( $file ) ) {
			return;
		}

		$originals_dir = $dir . DIRECTORY_SEPARATOR . 'originals';
		if ( ! wp_mkdir_p( $originals_dir ) ) {
			return;
		}

		$dest = $originals_dir . DIRECTORY_SEPARATOR . $basename;
		if ( $file === $dest ) {
			return;
		}

		if ( @rename( $file, $dest ) ) {
			return;
		}
		if ( @copy( $file, $dest ) ) {
			wp_delete_file( $file );
		}
	}

	/**
	 * @param array<string, mixed> $settings
	 * @return array{quality: int, speed: int}
	 */
	private function getEncodingParams( array $settings ): array {
		$preset = isset( $settings['preset'] ) ? (string) $settings['preset'] : 'balanced';
		switch ( $preset ) {
			case 'max_compression':
				return [ 'quality' => 62, 'speed' => 9 ];
			case 'max_quality':
				return [ 'quality' => 92, 'speed' => 4 ];
			case 'custom':
				$q = isset( $settings['quality'] ) ? (int) $settings['quality'] : 80;
				$s = isset( $settings['encoding_speed'] ) ? (int) $settings['encoding_speed'] : 6;
				return [
					'quality' => max( 0, min( 100, $q ) ),
					'speed'   => max( 0, min( 10, $s ) ),
				];
			default:
				return [ 'quality' => 80, 'speed' => 6 ];
		}
	}

	private function bumpStats( int $bytes_saved, string $rel ): void {
		++self::$pending_stats['images'];
		self::$pending_stats['bytes'] += $bytes_saved;
		array_unshift(
			self::$pending_stats['log'],
			[
				'time'  => time(),
				'file'  => $rel,
				'saved' => $bytes_saved,
			]
		);
		self::$pending_stats['log'] = array_slice( self::$pending_stats['log'], 0, 10 );

		if ( ! self::$stats_shutdown_registered ) {
			add_action( 'shutdown', [ $this, 'flushBufferedStats' ], 1 );
			self::$stats_shutdown_registered = true;
		}
	}

	public function flushBufferedStats(): void {
		if ( self::$pending_stats['images'] <= 0 ) {
			return;
		}

		$s = $this->moduleSettings()->all( [] );
		if ( ! is_array( $s ) ) {
			$s = [];
		}
		$s['stat_images_converted'] = (int) ( $s['stat_images_converted'] ?? 0 ) + self::$pending_stats['images'];
		$s['stat_bytes_saved']      = (int) ( $s['stat_bytes_saved'] ?? 0 ) + self::$pending_stats['bytes'];

		$log = $s['last_processed'] ?? [];
		if ( ! is_array( $log ) ) {
			$log = [];
		}
		$s['last_processed'] = array_slice( array_merge( self::$pending_stats['log'], $log ), 0, 10 );

		$this->moduleSettings()->save( $s );

		self::$pending_stats = [
			'images' => 0,
			'bytes'  => 0,
			'log'    => [],
		];
	}

	/**
	 * @return array<string, mixed>
	 */
	private function getSettings(): array {
		$raw = $this->moduleSettings()->all( [] );
		if ( ! is_array( $raw ) ) {
			$raw = [];
		}
		$preset = (string) ( $raw['preset'] ?? 'balanced' );

		return [
			'convert_jpeg'           => ! array_key_exists( 'convert_jpeg', $raw ) ? true : (bool) $raw['convert_jpeg'],
			'convert_png'            => ! array_key_exists( 'convert_png', $raw ) ? true : (bool) $raw['convert_png'],
			'preset'                 => in_array( $preset, [ 'balanced', 'max_compression', 'max_quality', 'custom' ], true )
				? $preset
				: 'balanced',
			'quality'                => isset( $raw['quality'] ) ? max( 0, min( 100, (int) $raw['quality'] ) ) : 80,
			'encoding_speed'         => isset( $raw['encoding_speed'] ) ? max( 0, min( 10, (int) $raw['encoding_speed'] ) ) : 6,
			'keep_original'          => ! array_key_exists( 'keep_original', $raw ) ? true : (bool) $raw['keep_original'],
			'replace_original'       => ! empty( $raw['replace_original'] ),
			'serve_avif_picture'     => ! array_key_exists( 'serve_avif_picture', $raw ) ? true : (bool) $raw['serve_avif_picture'],
			'sync_max_bytes'         => isset( $raw['sync_max_bytes'] ) ? max( 102400, min( 52428800, (int) $raw['sync_max_bytes'] ) ) : 1048576,
			'min_input_bytes'        => isset( $raw['min_input_bytes'] ) ? max( 0, min( 10485760, (int) $raw['min_input_bytes'] ) ) : 5120,
			'max_dimension'          => isset( $raw['max_dimension'] ) ? max( 0, min( 20000, (int) $raw['max_dimension'] ) ) : 0,
			'exclusion_suffix'       => substr(
				sanitize_text_field(
					is_string( $raw['exclusion_suffix'] ?? null ) ? $raw['exclusion_suffix'] : self::DEFAULT_EXCLUSION_SUFFIX
				),
				0,
				40
			),
			'allow_external_binary'  => ! empty( $raw['allow_external_binary'] ),
			'external_binary_path'   => $this->sanitizeExternalBinaryPath(
				is_string( $raw['external_binary_path'] ?? null ) ? $raw['external_binary_path'] : '',
				! empty( $raw['allow_external_binary'] )
			),
		];
	}

	private function sanitizeExternalBinaryPath( string $path, bool $allowed ): string {
		if ( ! $allowed ) {
			return '';
		}

		$path = trim( sanitize_text_field( $path ) );
		if ( $path === '' || preg_match( '/[\r\n;|&`$]/', $path ) ) {
			return '';
		}

		if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
			return '';
		}

		$real = realpath( $path );
		if ( $real === false || ! is_file( $real ) || ! is_executable( $real ) ) {
			return '';
		}

		return $real;
	}

	private function isAllowedUploadPath( string $file ): bool {
		$upload = wp_upload_dir();
		if ( ! empty( $upload['error'] ) ) {
			return false;
		}
		$base = wp_normalize_path( $upload['basedir'] );
		$norm = wp_normalize_path( $file );
		if ( $base === '' || $norm === '' ) {
			return false;
		}
		return str_starts_with( $norm, $base . '/' ) || $norm === $base;
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private function isAttachmentEligibleForConversion( int $attachment_id, string $file, array $settings ): bool {
		if ( ! $this->isAllowedUploadPath( $file ) || ! is_file( $file ) || ! is_readable( $file ) ) {
			return false;
		}

		$existing_main = get_post_meta( $attachment_id, self::META_REL_PATH, true );
		if ( is_string( $existing_main ) && $existing_main !== '' && $this->getUploadPathFromRelativeAvif( $existing_main ) !== '' ) {
			return false;
		}
		foreach ( $this->getStoredSizeRelPaths( $attachment_id ) as $rel ) {
			if ( $this->getUploadPathFromRelativeAvif( $rel ) !== '' ) {
				return false;
			}
		}

		$path_info = pathinfo( $file );
		$ext       = isset( $path_info['extension'] ) ? strtolower( (string) $path_info['extension'] ) : '';
		if ( ! in_array( $ext, self::CONVERTIBLE_EXTENSIONS, true ) ) {
			return false;
		}

		if ( ( $ext === 'jpg' || $ext === 'jpeg' ) && empty( $settings['convert_jpeg'] ) ) {
			return false;
		}
		if ( $ext === 'png' && empty( $settings['convert_png'] ) ) {
			return false;
		}

		$suffix = isset( $settings['exclusion_suffix'] ) ? (string) $settings['exclusion_suffix'] : self::DEFAULT_EXCLUSION_SUFFIX;
		$base   = $path_info['filename'] ?? '';
		if ( $suffix !== '' && $base !== '' && substr( $base, - strlen( $suffix ) ) === $suffix ) {
			return false;
		}

		$size = @filesize( $file );
		if ( $size === false ) {
			return false;
		}

		$min_in = (int) ( $settings['min_input_bytes'] ?? 5120 );
		if ( $min_in > 0 && $size < $min_in ) {
			return false;
		}

		$max_dim = (int) ( $settings['max_dimension'] ?? 0 );
		if ( $max_dim > 0 ) {
			$dims = @getimagesize( $file );
			if ( is_array( $dims ) && isset( $dims[0], $dims[1] ) ) {
				if ( (int) $dims[0] > $max_dim || (int) $dims[1] > $max_dim ) {
					return false;
				}
			}
		}

		return true;
	}

	private function getUploadPathFromRelativeAvif( string $rel ): string {
		$rel = str_replace( '\\', '/', trim( $rel ) );
		if (
			$rel === ''
			|| str_starts_with( $rel, '/' )
			|| preg_match( '/^[a-zA-Z]:\//', $rel )
			|| str_contains( $rel, '../' )
			|| str_contains( $rel, '/..' )
			|| strtolower( pathinfo( $rel, PATHINFO_EXTENSION ) ) !== 'avif'
		) {
			return '';
		}

		$upload = wp_upload_dir();
		if ( ! empty( $upload['error'] ) ) {
			return '';
		}

		$base = wp_normalize_path( $upload['basedir'] );
		$path = wp_normalize_path( $upload['basedir'] . '/' . ltrim( $rel, '/' ) );
		if ( $base === '' || $path === '' || ! ( str_starts_with( $path, $base . '/' ) || $path === $base ) ) {
			return '';
		}
		if ( ! is_file( $path ) ) {
			return '';
		}

		$real = realpath( $path );
		if ( $real === false ) {
			return '';
		}
		$real_norm = wp_normalize_path( $real );
		if ( ! ( str_starts_with( $real_norm, $base . '/' ) || $real_norm === $base ) ) {
			return '';
		}

		return $real;
	}

	/**
	 * @return array<string, string>
	 */
	private function getStoredSizeRelPaths( int $attachment_id ): array {
		$raw = get_post_meta( $attachment_id, self::META_SIZE_REL_PATHS, true );
		if ( ! is_array( $raw ) ) {
			return [];
		}

		$out = [];
		foreach ( $raw as $size => $rel ) {
			if ( is_string( $size ) && is_string( $rel ) && $rel !== '' ) {
				$out[ $size ] = $rel;
			}
		}
		return $out;
	}

	/**
	 * Wrap attachment images in &lt;picture&gt; when an AVIF exists (any size: themes usually pass large/medium, not full).
	 *
	 * @param string       $html
	 * @param int          $attachment_id
	 * @param string|int[] $size
	 * @param bool         $icon
	 * @param string[]     $attr
	 */
	public function filterAttachmentImageHtml( string $html, int $attachment_id, $size, bool $icon, array $attr ): string {
		if ( $icon || $html === '' || is_admin() ) {
			return $html;
		}
		if ( ! $this->attachmentHasUsableAvif( $attachment_id ) ) {
			return $html;
		}
		if ( ! $this->shouldOfferPictureForSize( $attachment_id, $size ) ) {
			return $html;
		}

		$avif_url = $this->getAvifUrlForAttachment( $attachment_id, $size );
		if ( $avif_url === '' ) {
			return $html;
		}

		$escaped_avif = esc_url( $avif_url );
		$count        = 0;
		$inner        = preg_replace( '/<img\s+/i', '<img ' . self::IMG_DATA_AVIF_PICTURE . ' ', $html, 1, $count );
		if ( $count === 0 ) {
			$inner = $html;
		}
		return "<picture><source srcset=\"{$escaped_avif}\" type=\"image/avif\" />{$inner}</picture>";
	}

	/**
	 * Post / block content: same &lt;picture&gt; offer when WordPress passes the img through wp_filter_content_tags.
	 *
	 * @param string $html            Full img element.
	 * @param string $context         e.g. the_content.
	 * @param int    $attachment_id 0 when unknown.
	 */
	public function filterContentImgTag( string $html, string $context, int $attachment_id ): string {
		if ( is_admin() || $html === '' ) {
			return $html;
		}
		if ( str_contains( $html, 'data-wpps-avif-picture=' ) ) {
			return $html;
		}
		if ( $attachment_id <= 0 && preg_match( '/class\s*=\s*["\'][^"\']*\bwp-image-(\d+)\b/i', $html, $m ) ) {
			$attachment_id = (int) $m[1];
		}
		if ( $attachment_id <= 0 ) {
			return $html;
		}
		if ( ! $this->attachmentHasUsableAvif( $attachment_id ) ) {
			return $html;
		}
		$avif_url = $this->getAvifUrlForImgHtml( $attachment_id, $html );
		if ( $avif_url === '' ) {
			return $html;
		}
		$escaped = esc_url( $avif_url );
		$count   = 0;
		$inner   = preg_replace( '/<img\s+/i', '<img ' . self::IMG_DATA_AVIF_PICTURE . ' ', $html, 1, $count );
		if ( $count === 0 ) {
			$inner = $html;
		}
		return "<picture><source srcset=\"{$escaped}\" type=\"image/avif\" />{$inner}</picture>";
	}

	/**
	 * @param string|int[] $size
	 */
	private function shouldOfferPictureForSize( int $attachment_id, $size ): bool {
		/**
		 * When true, only wrap size "full" (legacy behaviour). Default false so large/medium/theme sizes get AVIF too.
		 *
		 * @param bool               $full_size_only
		 * @param int                $attachment_id
		 * @param string|int[]|mixed $size
		 */
		if ( apply_filters( 'wp_powersuite_avif_picture_full_size_only', false, $attachment_id, $size ) ) {
			if ( $size === 'full' ) {
				return true;
			}
			if ( is_array( $size ) && isset( $size[0], $size[1] ) ) {
				$full = wp_get_attachment_image_src( $attachment_id, 'full' );
				if ( is_array( $full ) && isset( $full[1], $full[2] ) ) {
					return (int) $size[0] === (int) $full[1] && (int) $size[1] === (int) $full[2];
				}
			}
			return false;
		}
		return true;
	}

	/**
	 * @param string|int[]|null $size
	 */
	private function getAvifUrlForAttachment( int $attachment_id, $size = null ): string {
		$size_map = $this->getStoredSizeRelPaths( $attachment_id );
		if ( is_string( $size ) && $size !== '' && $size !== 'full' && isset( $size_map[ $size ] ) ) {
			return $this->getAvifUrlFromRelativePath( $size_map[ $size ] );
		}

		if ( is_array( $size ) && isset( $size[0], $size[1] ) ) {
			$matched = $this->getSizeNameForDimensions( $attachment_id, (int) $size[0], (int) $size[1] );
			if ( $matched !== '' && isset( $size_map[ $matched ] ) ) {
				return $this->getAvifUrlFromRelativePath( $size_map[ $matched ] );
			}
			if ( $this->isFullSizeDimensions( $attachment_id, (int) $size[0], (int) $size[1] ) ) {
				return $this->getFullAvifUrlForAttachment( $attachment_id );
			}
			return '';
		}

		if ( is_string( $size ) && $size !== '' && $size !== 'full' ) {
			return '';
		}

		return $this->getFullAvifUrlForAttachment( $attachment_id );
	}

	private function getAvifUrlForImgHtml( int $attachment_id, string $html ): string {
		if ( ! preg_match( '/\ssrc\s*=\s*["\']([^"\']+)["\']/i', $html, $m ) ) {
			return '';
		}

		$src_path = wp_parse_url( html_entity_decode( $m[1] ), PHP_URL_PATH );
		$src_base = is_string( $src_path ) ? wp_basename( rawurldecode( $src_path ) ) : '';
		if ( $src_base === '' ) {
			return '';
		}

		$metadata = wp_get_attachment_metadata( $attachment_id );
		if ( is_array( $metadata ) && ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
			$size_map = $this->getStoredSizeRelPaths( $attachment_id );
			foreach ( $metadata['sizes'] as $size_name => $size_data ) {
				if (
					is_string( $size_name )
					&& is_array( $size_data )
					&& isset( $size_data['file'] )
					&& is_string( $size_data['file'] )
					&& wp_basename( $size_data['file'] ) === $src_base
					&& isset( $size_map[ $size_name ] )
				) {
					return $this->getAvifUrlFromRelativePath( $size_map[ $size_name ] );
				}
			}
		}

		$full_url  = wp_get_attachment_url( $attachment_id );
		$full_path = is_string( $full_url ) ? wp_parse_url( $full_url, PHP_URL_PATH ) : '';
		if ( is_string( $full_path ) && wp_basename( rawurldecode( $full_path ) ) === $src_base ) {
			return $this->getFullAvifUrlForAttachment( $attachment_id );
		}

		return '';
	}

	private function getFullAvifUrlForAttachment( int $attachment_id ): string {
		$rel = get_post_meta( $attachment_id, self::META_REL_PATH, true );
		if ( ! is_string( $rel ) || $rel === '' ) {
			if ( get_post_mime_type( $attachment_id ) === 'image/avif' ) {
				$url = wp_get_attachment_url( $attachment_id );
				return is_string( $url ) ? $url : '';
			}
			return '';
		}
		return $this->getAvifUrlFromRelativePath( $rel );
	}

	private function getAvifUrlFromRelativePath( string $rel ): string {
		if ( $this->getUploadPathFromRelativeAvif( $rel ) === '' ) {
			return '';
		}
		$upload = wp_upload_dir();
		if ( ! empty( $upload['error'] ) ) {
			return '';
		}
		return $upload['baseurl'] . '/' . ltrim( str_replace( '\\', '/', $rel ), '/' );
	}

	private function getSizeNameForDimensions( int $attachment_id, int $width, int $height ): string {
		$metadata = wp_get_attachment_metadata( $attachment_id );
		if ( ! is_array( $metadata ) || empty( $metadata['sizes'] ) || ! is_array( $metadata['sizes'] ) ) {
			return '';
		}
		foreach ( $metadata['sizes'] as $size_name => $size_data ) {
			if (
				is_string( $size_name )
				&& is_array( $size_data )
				&& isset( $size_data['width'], $size_data['height'] )
				&& (int) $size_data['width'] === $width
				&& (int) $size_data['height'] === $height
			) {
				return $size_name;
			}
		}
		return '';
	}

	private function isFullSizeDimensions( int $attachment_id, int $width, int $height ): bool {
		$full = wp_get_attachment_image_src( $attachment_id, 'full' );
		return is_array( $full ) && isset( $full[1], $full[2] ) && (int) $full[1] === $width && (int) $full[2] === $height;
	}
}
