<?php
/**
 * Attachments processing file.
 *
 * @package Activitypub
 */

namespace Activitypub;

/**
 * Attachments processor class.
 *
 * Handles importing media attachments into the WordPress Media Library.
 * Creates full WordPress attachment posts that are searchable and manageable.
 *
 * For lightweight file caching without Media Library overhead, use the
 * Cache\Media, Cache\Avatar, and Cache\Emoji classes instead.
 *
 * @since 1.0.0
 */
class Attachments {
	/**
	 * Maximum width for imported images into Media Library.
	 *
	 * @var int
	 */
	const MAX_IMAGE_DIMENSION = 1200;

	/**
	 * Import attachments from an ActivityPub object and attach them to a post.
	 *
	 * Creates full WordPress attachment posts in the media library. Each attachment
	 * becomes a searchable, manageable attachment post that appears in the WordPress
	 * Media Library and is part of the user's content.
	 *
	 * Use this when:
	 * - Importing content that will be owned and editable by the user.
	 * - You need WordPress attachment posts with full metadata support.
	 * - Media should be searchable and manageable in the Media Library.
	 * - Working with content that will be part of the user's site (e.g., importers).
	 *
	 * @param array $attachments Array of ActivityPub attachment objects.
	 * @param int   $post_id     The post ID to attach files to.
	 * @param int   $author_id   Optional. User ID to set as attachment author. Default 0.
	 *
	 * @return array Array of attachment IDs.
	 */
	public static function import( $attachments, $post_id, $author_id = 0 ) {
		// First, import inline images from the post content.
		$inline_mappings = self::import_inline_images( $post_id, $author_id );

		if ( empty( $attachments ) || ! is_array( $attachments ) ) {
			return array();
		}

		$attachment_ids = array();
		foreach ( $attachments as $attachment ) {
			$attachment_data = self::normalize_attachment( $attachment );

			if ( empty( $attachment_data['url'] ) ) {
				continue;
			}

			// Skip if this URL was already processed as an inline image.
			if ( isset( $inline_mappings[ $attachment_data['url'] ] ) ) {
				continue;
			}

			$attachment_id = self::save_attachment( $attachment_data, $post_id, $author_id );

			if ( ! \is_wp_error( $attachment_id ) ) {
				$attachment_ids[] = $attachment_id;
			}
		}

		// Append media markup to post content.
		if ( ! empty( $attachment_ids ) ) {
			self::append_media_to_post_content( $post_id, $attachment_ids );
		}

		return $attachment_ids;
	}

	/**
	 * Check if an attachment with the same source URL already exists for a post.
	 *
	 * @param string $source_url The source URL to check.
	 * @param int    $post_id    The post ID to check attachments for.
	 *
	 * @return int|false The existing attachment ID or false if not found.
	 */
	private static function get_existing_attachment( $source_url, $post_id ) {
		foreach ( \get_attached_media( '', $post_id ) as $attachment ) {
			if ( \get_post_meta( $attachment->ID, '_source_url', true ) === $source_url ) {
				return $attachment->ID;
			}
		}

		return false;
	}

	/**
	 * Process inline images from post content.
	 *
	 * @param int $post_id    The post ID.
	 * @param int $author_id  Optional. User ID to set as attachment author. Default 0.
	 *
	 * @return array Array of URL mappings (old URL => new URL).
	 */
	private static function import_inline_images( $post_id, $author_id = 0 ) {
		$post = \get_post( $post_id );
		if ( ! $post || empty( $post->post_content ) ) {
			return array();
		}

		// Find all img tags in the content.
		preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $post->post_content, $matches );

		if ( empty( $matches[1] ) ) {
			return array();
		}

		$url_mappings = array();
		$content      = $post->post_content;

		foreach ( $matches[1] as $image_url ) {
			// Skip if already processed or is a local URL.
			if ( isset( $url_mappings[ $image_url ] ) ) {
				continue;
			}

			// Check if this image was already processed as an attachment.
			$attachment_id = self::get_existing_attachment( $image_url, $post_id );
			if ( ! $attachment_id ) {
				$attachment_id = self::save_attachment( array( 'url' => $image_url ), $post_id, $author_id );

				if ( \is_wp_error( $attachment_id ) ) {
					continue;
				}
			}

			$new_url = \wp_get_attachment_url( $attachment_id );
			if ( $new_url ) {
				$url_mappings[ $image_url ] = $new_url;
				$content                    = \str_replace( $image_url, $new_url, $content );
			}
		}

		// Update post content if URLs were replaced.
		if ( ! empty( $url_mappings ) ) {
			\wp_update_post(
				array(
					'ID'           => $post_id,
					'post_content' => $content,
				)
			);
		}

		return $url_mappings;
	}

	/**
	 * Normalize an ActivityPub attachment object to a standard format.
	 *
	 * @param mixed $attachment The attachment data (array or object).
	 *
	 * @return array|false Normalized attachment data or false on failure.
	 */
	private static function normalize_attachment( $attachment ) {
		// Convert object to array if needed.
		if ( \is_object( $attachment ) ) {
			$attachment = \get_object_vars( $attachment );
		}

		if ( ! is_array( $attachment ) || empty( $attachment['url'] ) ) {
			return false;
		}

		return array(
			'url'       => $attachment['url'],
			'mediaType' => $attachment['mediaType'] ?? '',
			'name'      => $attachment['name'] ?? '',
			'type'      => $attachment['type'] ?? 'Document',
		);
	}

	/**
	 * Save an attachment (local file or remote URL) to the media library.
	 *
	 * @param array $attachment_data The normalized attachment data.
	 * @param int   $post_id         The post ID to attach to.
	 * @param int   $author_id       Optional. User ID to set as attachment author. Default 0.
	 *
	 * @return int|\WP_Error The attachment ID or WP_Error on failure.
	 */
	private static function save_attachment( $attachment_data, $post_id, $author_id = 0 ) {
		// Ensure required WordPress functions are loaded.
		if ( ! \function_exists( 'media_handle_sideload' ) || ! \function_exists( 'download_url' ) ) {
			require_once ABSPATH . 'wp-admin/includes/media.php';
			require_once ABSPATH . 'wp-admin/includes/file.php';
			require_once ABSPATH . 'wp-admin/includes/image.php';
		}

		// Use WP_Filesystem_Direct explicitly to avoid FTP fallback from WP_Filesystem().
		require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
		require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';

		$filesystem = new \WP_Filesystem_Direct( null );

		$is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] );

		if ( $is_local ) {
			// Validate local path is within allowed directories to prevent file disclosure.
			$allowed = self::is_allowed_local_path( $attachment_data['url'] );
			if ( ! $allowed ) {
				return new \WP_Error( 'invalid_path', \__( 'Local file path is not within allowed directories.', 'activitypub' ) );
			}

			// Read local file from disk.
			if ( ! $filesystem->exists( $attachment_data['url'] ) ) {
				/* translators: %s: file path */
				return new \WP_Error( 'file_not_found', sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) );
			}

			// Copy to temp file so media_handle_sideload doesn't move the original.
			$tmp_file = \wp_tempnam( \basename( $attachment_data['url'] ) );
			$filesystem->copy( $attachment_data['url'], $tmp_file, true );
		} else {
			// Validate remote URL before downloading.
			if ( ! \wp_http_validate_url( $attachment_data['url'] ) ) {
				return new \WP_Error( 'invalid_url', \__( 'URL is not allowed.', 'activitypub' ) );
			}

			// Download remote URL.
			$tmp_file = \download_url( $attachment_data['url'] );

			if ( \is_wp_error( $tmp_file ) ) {
				return $tmp_file;
			}
		}

		// Get original filename from URL.
		$original_name = \basename( \wp_parse_url( $attachment_data['url'], PHP_URL_PATH ) );

		// Rename temp file to have proper extension for optimize_image to detect mime type.
		$original_ext = \pathinfo( $original_name, PATHINFO_EXTENSION );
		if ( $original_ext ) {
			$renamed_tmp = $tmp_file . '.' . $original_ext;
			if ( $filesystem->move( $tmp_file, $renamed_tmp, true ) ) {
				$tmp_file = $renamed_tmp;
			}
		}

		// Optimize images before sideloading (resize and convert to WebP).
		$tmp_file = self::optimize_image( $tmp_file, self::MAX_IMAGE_DIMENSION );

		// Update filename extension to match optimized file.
		$new_ext = \pathinfo( $tmp_file, PATHINFO_EXTENSION );
		if ( $new_ext ) {
			$original_name = \preg_replace( '/\.[^.]+$/', '.' . $new_ext, $original_name );
		}

		$file_array = array(
			'name'     => $original_name,
			'tmp_name' => $tmp_file,
		);

		// Prepare attachment post data.
		// Let WordPress auto-detect the mime type from the file.
		$post_data = array(
			'post_title'   => $attachment_data['name'] ?? '',
			'post_content' => $attachment_data['name'] ?? '',
			'post_author'  => $author_id,
			'meta_input'   => array(
				'_source_url' => $attachment_data['url'],
			),
		);

		// Add alt text for images.
		if ( ! empty( $attachment_data['name'] ) ) {
			$original_mime = $attachment_data['mediaType'] ?? '';
			if ( 'image' === strtok( $original_mime, '/' ) ) {
				$post_data['meta_input']['_wp_attachment_image_alt'] = $attachment_data['name'];
			}
		}

		// Sideload the attachment into WordPress.
		$attachment_id = \media_handle_sideload( $file_array, $post_id, '', $post_data );

		// Clean up temp file if there was an error.
		if ( \is_wp_error( $attachment_id ) ) {
			\wp_delete_file( $tmp_file );
		}

		return $attachment_id;
	}

	/**
	 * Get a unique file path by appending a counter if the file already exists.
	 *
	 * @param string $file_path The desired file path.
	 *
	 * @return string A unique file path that doesn't exist.
	 */
	private static function get_unique_path( $file_path ) {
		if ( ! \file_exists( $file_path ) ) {
			return $file_path;
		}

		$path_info = \pathinfo( $file_path );
		$dir       = $path_info['dirname'];
		$base_name = $path_info['filename'];
		$extension = isset( $path_info['extension'] ) ? '.' . $path_info['extension'] : '';
		$counter   = 1;

		do {
			$new_path = $dir . '/' . $base_name . '-' . $counter . $extension;
			++$counter;
		} while ( \file_exists( $new_path ) );

		return $new_path;
	}

	/**
	 * Check if a local file path is within allowed directories.
	 *
	 * Prevents arbitrary file access by restricting local paths to known safe
	 * directories like the uploads folder or WordPress temp directory.
	 *
	 * @param string $file_path The local file path to validate.
	 *
	 * @return bool True if the path is allowed, false otherwise.
	 */
	private static function is_allowed_local_path( $file_path ) {
		// Normalize the path and resolve any relative components.
		$real_path = \realpath( $file_path );
		if ( false === $real_path ) {
			// If file doesn't exist yet, check the directory.
			$dir_path = \realpath( \dirname( $file_path ) );
			if ( false === $dir_path ) {
				return false;
			}
			$real_path = $dir_path . '/' . \basename( $file_path );
		}

		// Get allowed base directories.
		$upload_dir   = \wp_upload_dir();
		$allowed_dirs = array(
			\realpath( $upload_dir['basedir'] ),
			\realpath( \get_temp_dir() ),
			\realpath( ABSPATH . 'wp-content' ),
		);

		/**
		 * Filters the allowed directories for local file imports.
		 *
		 * @since 5.6.0
		 *
		 * @param string[] $allowed_dirs Array of allowed directory paths.
		 * @param string   $file_path    The file path being validated.
		 */
		$allowed_dirs = \apply_filters( 'activitypub_allowed_import_directories', $allowed_dirs, $file_path );

		// Remove any false values from realpath failures.
		$allowed_dirs = \array_filter( $allowed_dirs );

		// Check if the file is within any allowed directory.
		foreach ( $allowed_dirs as $allowed_dir ) {
			if ( \str_starts_with( $real_path, $allowed_dir ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Optimize an image file by resizing and converting to WebP.
	 *
	 * Uses WordPress image editor to resize large images and convert them
	 * to WebP format for better compression while maintaining quality.
	 *
	 * @param string $file_path     Path to the image file.
	 * @param int    $max_dimension Maximum width/height in pixels.
	 *
	 * @return string The optimized file path.
	 */
	private static function optimize_image( $file_path, $max_dimension ) {
		// Check if it's an image.
		$mime_type = \wp_check_filetype( $file_path )['type'] ?? '';
		if ( ! $mime_type || ! \str_starts_with( $mime_type, 'image/' ) ) {
			return $file_path;
		}

		// Skip SVG and GIF files (GIFs may be animated).
		if ( \in_array( $mime_type, array( 'image/svg+xml', 'image/gif' ), true ) ) {
			return $file_path;
		}

		$editor = \wp_get_image_editor( $file_path );
		if ( \is_wp_error( $editor ) ) {
			return $file_path;
		}

		$size         = $editor->get_size();
		$needs_resize = $size['width'] > $max_dimension || $size['height'] > $max_dimension;

		// Resize if needed.
		if ( $needs_resize ) {
			$editor->resize( $max_dimension, $max_dimension, false );
		}

		// Check if WebP is supported.
		$can_webp = $editor->supports_mime_type( 'image/webp' );

		// Determine output format and save.
		if ( $can_webp ) {
			// Convert to WebP.
			$new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.webp', $file_path ) );
			$result   = $editor->save( $new_path, 'image/webp' );
		} elseif ( \in_array( $mime_type, array( 'image/png', 'image/webp' ), true ) ) {
			// Keep original format for potentially transparent images when WebP not available.
			if ( ! $needs_resize ) {
				// No changes needed.
				return $file_path;
			}
			$result = $editor->save( $file_path );
		} else {
			// Convert to JPEG when WebP not available.
			$new_path = self::get_unique_path( \preg_replace( '/\.[^.]+$/', '.jpg', $file_path ) );
			$result   = $editor->save( $new_path, 'image/jpeg' );
		}

		if ( \is_wp_error( $result ) ) {
			return $file_path;
		}

		// Handle result - $result is always an array from $editor->save().
		$result_path = $result['path'] ?? $file_path;

		// If path changed (format conversion), delete the original file.
		if ( $result_path !== $file_path ) {
			\wp_delete_file( $file_path );
		}

		return $result_path;
	}

	/**
	 * Append media to post content.
	 *
	 * @param int   $post_id        The post ID.
	 * @param int[] $attachment_ids Array of attachment IDs.
	 */
	private static function append_media_to_post_content( $post_id, $attachment_ids ) {
		$post = \get_post( $post_id );
		if ( ! $post ) {
			return;
		}

		$media     = self::generate_media_markup( $attachment_ids );
		$separator = empty( trim( $post->post_content ) ) ? '' : "\n\n";

		\wp_update_post(
			array(
				'ID'           => $post_id,
				'post_content' => $post->post_content . $separator . $media,
			)
		);
	}

	/**
	 * Generate media markup for attachments.
	 *
	 * @param int[] $attachment_ids Array of attachment IDs.
	 *
	 * @return string The generated markup.
	 */
	private static function generate_media_markup( $attachment_ids ) {
		if ( empty( $attachment_ids ) ) {
			return '';
		}

		/**
		 * Filters the media markup for ActivityPub attachments.
		 *
		 * Allows plugins to provide custom markup for attachments.
		 * If this filter returns a non-empty string, it will be used instead of
		 * the default block markup.
		 *
		 * @param string $markup         The custom markup. Default empty string.
		 * @param int[]  $attachment_ids Array of attachment IDs.
		 */
		$custom_markup = \apply_filters( 'activitypub_attachments_media_markup', '', $attachment_ids );

		if ( ! empty( $custom_markup ) ) {
			return $custom_markup;
		}

		// Default to block markup.
		$type = strtok( \get_post_mime_type( $attachment_ids[0] ), '/' );

		// Single video or audio file.
		if ( 1 === \count( $attachment_ids ) && ( 'video' === $type || 'audio' === $type ) ) {
			return sprintf(
				'<!-- wp:%1$s {"id":"%2$s"} --><figure class="wp-block-%1$s"><%1$s controls src="%3$s"></%1$s></figure><!-- /wp:%1$s -->',
				\esc_attr( $type ),
				\esc_attr( $attachment_ids[0] ),
				\esc_url( \wp_get_attachment_url( $attachment_ids[0] ) )
			);
		}

		// Single image: use standalone image block.
		if ( 1 === \count( $attachment_ids ) && 'image' === $type ) {
			return self::get_image_block( $attachment_ids[0] );
		}

		// Multiple attachments: use gallery block.
		return self::get_gallery_block( $attachment_ids );
	}

	/**
	 * Get standalone image block markup.
	 *
	 * @param int $attachment_id The attachment ID.
	 *
	 * @return string The image block markup.
	 */
	private static function get_image_block( $attachment_id ) {
		$image_src = \wp_get_attachment_image_src( $attachment_id, 'large' );
		if ( ! $image_src ) {
			return '';
		}

		$alt = \get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
		if ( ! $alt ) {
			$alt = \get_post_field( 'post_excerpt', $attachment_id );
		}

		$block  = '<!-- wp:image {"id":' . \esc_attr( $attachment_id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
		$block .= '<figure class="wp-block-image size-large">';
		$block .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $attachment_id ) . '"/>';
		$block .= '</figure>' . "\n";
		$block .= '<!-- /wp:image -->';

		return $block;
	}

	/**
	 * Get gallery block markup.
	 *
	 * @param int[] $attachment_ids The attachment IDs to use.
	 *
	 * @return string The gallery block markup.
	 */
	private static function get_gallery_block( $attachment_ids ) {
		$gallery  = '<!-- wp:gallery {"columns":2,"linkTo":"none","sizeSlug":"large","imageCrop":true} -->' . "\n";
		$gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';

		foreach ( $attachment_ids as $id ) {
			$image_src = \wp_get_attachment_image_src( $id, 'large' );
			if ( ! $image_src ) {
				continue;
			}

			$alt = \get_post_meta( $id, '_wp_attachment_image_alt', true );
			if ( ! $alt ) {
				$alt = \get_post_field( 'post_excerpt', $id );
			}

			$gallery .= "\n" . '<!-- wp:image {"id":' . \esc_attr( $id ) . ',"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
			$gallery .= '<figure class="wp-block-image size-large">';
			$gallery .= '<img src="' . \esc_url( $image_src[0] ) . '" alt="' . \esc_attr( $alt ) . '" class="' . \esc_attr( 'wp-image-' . $id ) . '"/>';
			$gallery .= '</figure>';
			$gallery .= "\n<!-- /wp:image -->\n";
		}

		$gallery .= "</figure>\n";
		$gallery .= '<!-- /wp:gallery -->';

		return $gallery;
	}

	/**
	 * Get content from an object based on its type.
	 *
	 * @param int    $object_id   The object ID (post or comment).
	 * @param string $object_type The object type ('post' or 'comment').
	 *
	 * @return string The object content.
	 */
	private static function get_object_content( $object_id, $object_type ) {
		if ( 'comment' === $object_type ) {
			$comment = \get_comment( $object_id );
			return $comment ? $comment->comment_content : '';
		}

		return \get_post_field( 'post_content', $object_id );
	}

	/**
	 * Update content for an object based on its type.
	 *
	 * @param int    $object_id   The object ID (post or comment).
	 * @param string $object_type The object type ('post' or 'comment').
	 * @param string $content     The new content.
	 */
	private static function update_object_content( $object_id, $object_type, $content ) {
		if ( 'comment' === $object_type ) {
			\wp_update_comment(
				array(
					'comment_ID'      => $object_id,
					'comment_content' => $content,
				)
			);
		} else {
			\wp_update_post(
				array(
					'ID'           => $object_id,
					'post_content' => $content,
				)
			);
		}
	}

	/**
	 * Append file-based media markup to an object's content.
	 *
	 * Used for cached remote media (via Cache classes) that doesn't go through
	 * the Media Library. Works with posts and comments.
	 *
	 * @param int    $object_id   The object ID (post or comment).
	 * @param array  $files       Array of file data arrays with 'url', 'mime_type', and 'alt' keys.
	 * @param string $object_type The object type ('post' or 'comment').
	 */
	public static function append_files_to_content( $object_id, $files, $object_type = 'post' ) {
		$content = self::get_object_content( $object_id, $object_type );
		if ( empty( $content ) ) {
			return;
		}

		$media     = self::generate_files_markup( $files );
		$separator = empty( trim( $content ) ) ? '' : "\n\n";

		self::update_object_content( $object_id, $object_type, $content . $separator . $media );
	}

	/**
	 * Generate media markup for file-based attachments.
	 *
	 * Creates WordPress block markup from file data arrays. Used for cached
	 * remote media that doesn't have WordPress attachment posts.
	 *
	 * @param array[] $files {
	 *     Array of file data arrays.
	 *
	 *     @type string $url       Full URL to the file.
	 *     @type string $mime_type MIME type of the file.
	 *     @type string $alt       Alt text for the file.
	 * }
	 *
	 * @return string The generated markup.
	 */
	public static function generate_files_markup( $files ) {
		if ( empty( $files ) ) {
			return '';
		}

		/**
		 * Filters the media markup for ActivityPub file-based attachments.
		 *
		 * Allows plugins to provide custom markup for file-based attachments.
		 * If this filter returns a non-empty string, it will be used instead of
		 * the default block markup.
		 *
		 * @param string $markup The custom markup. Default empty string.
		 * @param array  $files  Array of file data arrays.
		 */
		$custom_markup = \apply_filters( 'activitypub_files_media_markup', '', $files );

		if ( ! empty( $custom_markup ) ) {
			return $custom_markup;
		}

		// Default to block markup.
		$type = strtok( $files[0]['mime_type'], '/' );

		// Single video or audio file.
		if ( 1 === \count( $files ) && ( 'video' === $type || 'audio' === $type ) ) {
			return sprintf(
				'<!-- wp:%1$s --><figure class="wp-block-%1$s"><%1$s controls src="%2$s"></%1$s></figure><!-- /wp:%1$s -->',
				\esc_attr( $type ),
				\esc_url( $files[0]['url'] )
			);
		}

		// Single image: use standalone image block.
		if ( 1 === \count( $files ) && 'image' === $type ) {
			return self::get_files_image_block( $files[0] );
		}

		// Multiple attachments: use gallery block.
		return self::get_files_gallery_block( $files );
	}

	/**
	 * Get standalone image block markup for file-based attachments.
	 *
	 * @param array $file {
	 *     File data array.
	 *
	 *     @type string $url       Full URL to the file.
	 *     @type string $mime_type MIME type of the file.
	 *     @type string $alt       Alt text for the file.
	 * }
	 *
	 * @return string The image block markup.
	 */
	public static function get_files_image_block( $file ) {
		$block  = '<!-- wp:image {"sizeSlug":"large","linkDestination":"none"} -->' . "\n";
		$block .= '<figure class="wp-block-image size-large">';
		$block .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
		$block .= '</figure>' . "\n";
		$block .= '<!-- /wp:image -->';

		return $block;
	}

	/**
	 * Get gallery block markup for file-based attachments.
	 *
	 * @param array[] $files {
	 *     Array of file data arrays.
	 *
	 *     @type string $url       Full URL to the file.
	 *     @type string $mime_type MIME type of the file.
	 *     @type string $alt       Alt text for the file.
	 * }
	 *
	 * @return string The gallery block markup.
	 */
	public static function get_files_gallery_block( $files ) {
		$gallery  = '<!-- wp:gallery {"columns":2,"linkTo":"none","imageCrop":true} -->' . "\n";
		$gallery .= '<figure class="wp-block-gallery has-nested-images columns-2 is-cropped">';

		foreach ( $files as $file ) {
			$gallery .= "\n<!-- wp:image {\"sizeSlug\":\"large\",\"linkDestination\":\"none\"} -->\n";
			$gallery .= '<figure class="wp-block-image size-large">';
			$gallery .= '<img src="' . \esc_url( $file['url'] ) . '" alt="' . \esc_attr( $file['alt'] ?? '' ) . '"/>';
			$gallery .= '</figure>';
			$gallery .= "\n<!-- /wp:image -->\n";
		}

		$gallery .= "</figure>\n";
		$gallery .= '<!-- /wp:gallery -->';

		return $gallery;
	}
}
