<?php
/**
 * Media library tools — upload from URL, list attachments, get one.
 *
 * Upload is SSRF-guarded (no localhost / private / reserved IP ranges).
 * Mime allowlist follows WordPress's own `wp_get_mime_types`.
 */

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

class PCD_PC_Tools_Media {

	public static function register(): void {
		PCD_PC_Tools::register(
			'wp_media_upload',
			'Download an image (or other allowed media) from a URL and add it to the WordPress media library. Returns the new attachment ID + permalink.',
			[
				'type' => 'object',
				'required' => [ 'url' ],
				'properties' => [
					'url'      => [ 'type' => 'string', 'description' => 'Public HTTPS URL to download from.' ],
					'title'    => [ 'type' => 'string' ],
					'alt_text' => [ 'type' => 'string' ],
					'caption'  => [ 'type' => 'string' ],
					'post_id'  => [ 'type' => 'integer', 'description' => 'Attach to this post.' ],
				],
			],
			[ __CLASS__, 'upload' ],
			[ 'cap' => 'upload_files', 'category' => 'media' ]
		);

		PCD_PC_Tools::register(
			'wp_media_list',
			'List attachments in the media library, filterable by MIME prefix (e.g. "image", "image/jpeg", "video", "application/pdf").',
			[
				'type' => 'object',
				'properties' => [
					'mime'     => [ 'type' => 'string', 'description' => 'MIME prefix filter, e.g. image/* or video.' ],
					'search'   => [ 'type' => 'string' ],
					'per_page' => [ 'type' => 'integer', 'default' => 50 ],
					'page'     => [ 'type' => 'integer', 'default' => 1 ],
				],
			],
			[ __CLASS__, 'listing' ],
			[ 'cap' => 'upload_files', 'category' => 'media' ]
		);

		PCD_PC_Tools::register(
			'wp_media_get',
			'Get a single attachment by ID — URL, alt text, dimensions, mime, sizes.',
			[
				'type' => 'object',
				'required' => [ 'id' ],
				'properties' => [ 'id' => [ 'type' => 'integer' ] ],
			],
			[ __CLASS__, 'get' ],
			[ 'cap' => 'upload_files', 'category' => 'media' ]
		);

		PCD_PC_Tools::register(
			'wp_media_delete',
			'Trash an attachment. Pass force=true to permanently delete (also removes the underlying file).',
			[
				'type' => 'object',
				'required' => [ 'id' ],
				'properties' => [
					'id'      => [ 'type' => 'integer' ],
					'force'   => [ 'type' => 'boolean', 'default' => false ],
					'confirm' => [ 'type' => 'boolean' ],
				],
			],
			[ __CLASS__, 'delete' ],
			[ 'cap' => 'delete_posts', 'category' => 'media', 'destructive' => true ]
		);

		PCD_PC_Tools::register(
			'wp_media_optimize',
			'On-demand: shrink existing media-library images by resizing oversized originals down to a max width and re-compressing them through the PCD Image Optimizer pipeline on the Conduit relay (sharp/libvips, markedly better than WordPress\'s built-in GD). Two-phase: first call returns a preview (which images, current sizes, planned actions); pass confirm=true to apply. OVERWRITES the original files (lossy, not undoable) and regenerates thumbnails. Smart per-format handling: JPEGs re-encode as JPEG (mozjpeg). PNGs WITH transparency stay PNG (effort:8 palette compression). PNGs WITHOUT transparency are converted to JPEG by default — a much bigger compression win — and the plugin migrates ALL references to the old `.png` URL to the new `.jpg` URL across post_content, options, and postmeta (with serialization safety, so Bricks layouts and serialized plugin data are not corrupted). Pass convert_opaque_png=false to disable that conversion if you need PNG file paths preserved exactly. Batched — process up to 25 per call; re-call for more. Requires the site to be paired with Conduit. Images larger than 4MB are skipped (relay request-size limit).',
			[
				'type' => 'object',
				'properties' => [
					'ids'         => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ], 'description' => 'Specific attachment IDs. Omit to scan the library (largest images first).' ],
					'max_width'   => [ 'type' => 'integer', 'default' => 1200, 'description' => 'Resize any image wider than this down to it (proportional). 1200 matches the PCD Image Optimizer default. The main source of savings.' ],
					'quality'     => [ 'type' => 'integer', 'default' => 82, 'minimum' => 40, 'maximum' => 100, 'description' => 'Re-compress quality (JPEG/WebP/PNG). 80–90 is the recommended range; 82 is a good balance.' ],
					'convert_opaque_png' => [ 'type' => 'boolean', 'default' => true, 'description' => 'When true (default), opaque (non-transparent) PNGs are converted to JPEG for major size savings, and references to the old .png URL are automatically migrated to the new .jpg URL across the database. Transparent PNGs are ALWAYS left as PNG regardless of this flag. Set false if you specifically need PNG file paths preserved exactly.' ],
					'min_size_kb' => [ 'type' => 'integer', 'default' => 150, 'description' => 'Skip images already smaller than this — not worth processing.' ],
					'per_call'    => [ 'type' => 'integer', 'default' => 25, 'minimum' => 1, 'maximum' => 25, 'description' => 'Max images to process this call.' ],
					'confirm'     => [ 'type' => 'boolean', 'default' => false ],
				],
			],
			[ __CLASS__, 'optimize' ],
			[ 'cap' => 'upload_files', 'category' => 'media' ]
		);
	}

	public static function upload( array $args ) {
		$url      = esc_url_raw( (string) ( $args['url'] ?? '' ) );
		$title    = sanitize_text_field( (string) ( $args['title'] ?? '' ) );
		$alt      = sanitize_text_field( (string) ( $args['alt_text'] ?? '' ) );
		$caption  = sanitize_textarea_field( (string) ( $args['caption'] ?? '' ) );
		$post_id  = (int) ( $args['post_id'] ?? 0 );

		if ( ! $url ) {
			return new WP_Error( 'invalid_url', 'URL is required.' );
		}
		if ( ! preg_match( '#^https?://#i', $url ) ) {
			return new WP_Error( 'invalid_url', 'URL must be http(s).' );
		}

		// SSRF guard — block localhost / private / reserved ranges.
		$host = wp_parse_url( $url, PHP_URL_HOST );
		if ( ! $host ) {
			return new WP_Error( 'invalid_url', 'Could not parse host.' );
		}
		$ip = gethostbyname( $host );
		if ( $ip && $ip !== $host ) {
			if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
				return new WP_Error( 'blocked_url', "Refusing to download from {$host} ({$ip}) — private or reserved IP range." );
			}
		}

		require_once ABSPATH . 'wp-admin/includes/media.php';
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';

		$tmp = download_url( $url, 30 );
		if ( is_wp_error( $tmp ) ) {
			return new WP_Error( 'download_failed', 'Failed to download: ' . $tmp->get_error_message() );
		}

		// Derive a filename.
		$url_path = wp_parse_url( $url, PHP_URL_PATH );
		$filename = $title ? sanitize_file_name( $title ) : basename( (string) $url_path );
		if ( ! pathinfo( $filename, PATHINFO_EXTENSION ) ) {
			$mime = function_exists( 'mime_content_type' ) ? mime_content_type( $tmp ) : '';
			$ext_map = [
				'image/jpeg'    => '.jpg',
				'image/png'     => '.png',
				'image/gif'     => '.gif',
				'image/webp'    => '.webp',
				'image/svg+xml' => '.svg',
				'video/mp4'     => '.mp4',
				'application/pdf' => '.pdf',
			];
			$filename .= $ext_map[ $mime ] ?? '.jpg';
		}

		$file_array = [ 'name' => $filename, 'tmp_name' => $tmp ];
		$attachment_id = media_handle_sideload( $file_array, $post_id, $title ?: null );

		if ( is_wp_error( $attachment_id ) ) {
			wp_delete_file( $tmp );
			return new WP_Error( 'upload_failed', 'Sideload failed: ' . $attachment_id->get_error_message() );
		}

		if ( $alt ) {
			update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt );
		}
		if ( $caption ) {
			wp_update_post( [ 'ID' => $attachment_id, 'post_excerpt' => $caption ] );
		}
		if ( $title ) {
			wp_update_post( [ 'ID' => $attachment_id, 'post_title' => $title ] );
		}

		$meta = wp_get_attachment_metadata( $attachment_id );

		return [
			'id'        => $attachment_id,
			'url'       => wp_get_attachment_url( $attachment_id ),
			'title'     => get_the_title( $attachment_id ),
			'alt_text'  => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
			'caption'   => get_post_field( 'post_excerpt', $attachment_id ),
			'mime'      => get_post_mime_type( $attachment_id ),
			'width'     => $meta['width'] ?? null,
			'height'    => $meta['height'] ?? null,
		];
	}

	public static function listing( array $args ): array {
		$mime     = (string) ( $args['mime'] ?? '' );
		$search   = (string) ( $args['search'] ?? '' );
		$per_page = min( 100, max( 1, (int) ( $args['per_page'] ?? 50 ) ) );
		$page     = max( 1, (int) ( $args['page'] ?? 1 ) );

		$query_args = [
			'post_type'      => 'attachment',
			'post_status'    => 'inherit',
			'posts_per_page' => $per_page,
			'paged'          => $page,
			'orderby'        => 'date',
			'order'          => 'DESC',
		];
		if ( $mime ) {
			$query_args['post_mime_type'] = $mime;
		}
		if ( $search ) {
			$query_args['s'] = $search;
		}

		$q = new WP_Query( $query_args );
		$rows = [];
		foreach ( $q->posts as $p ) {
			$meta = wp_get_attachment_metadata( $p->ID );
			$rows[] = [
				'id'    => $p->ID,
				'title' => $p->post_title,
				'url'   => wp_get_attachment_url( $p->ID ),
				'mime'  => $p->post_mime_type,
				'date'  => $p->post_date,
				'width' => $meta['width'] ?? null,
				'height'=> $meta['height'] ?? null,
			];
		}

		return [
			'count'      => count( $rows ),
			'total'      => (int) $q->found_posts,
			'page'       => $page,
			'per_page'   => $per_page,
			'attachments'=> $rows,
		];
	}

	public static function get( array $args ) {
		$id = (int) ( $args['id'] ?? 0 );
		$p  = get_post( $id );
		if ( ! $p || 'attachment' !== $p->post_type ) {
			return new WP_Error( 'not_found', "Attachment #{$id} not found." );
		}
		$meta = wp_get_attachment_metadata( $id );
		$sizes = [];
		if ( is_array( $meta['sizes'] ?? null ) ) {
			foreach ( $meta['sizes'] as $size => $info ) {
				$sizes[ $size ] = [
					'url'    => wp_get_attachment_image_url( $id, $size ),
					'width'  => $info['width'] ?? null,
					'height' => $info['height'] ?? null,
				];
			}
		}
		return [
			'id'       => $p->ID,
			'title'    => $p->post_title,
			'url'      => wp_get_attachment_url( $id ),
			'alt_text' => get_post_meta( $id, '_wp_attachment_image_alt', true ),
			'caption'  => $p->post_excerpt,
			'mime'     => $p->post_mime_type,
			'date'     => $p->post_date,
			'width'    => $meta['width'] ?? null,
			'height'   => $meta['height'] ?? null,
			'file_size'=> $meta['filesize'] ?? null,
			'sizes'    => $sizes,
		];
	}

	public static function delete( array $args ) {
		$id = (int) ( $args['id'] ?? 0 );
		$p  = get_post( $id );
		if ( ! $p || 'attachment' !== $p->post_type ) {
			return new WP_Error( 'not_found', "Attachment #{$id} not found." );
		}
		PCD_PC_Snapshot::capture( 'post', (string) $id, [
			'ID'          => $id,
			'post_title'  => $p->post_title,
			'post_status' => $p->post_status,
		], "Attachment #{$id} before delete" );

		$force  = ! empty( $args['force'] );
		$result = wp_delete_attachment( $id, $force );
		return $result ? [ 'id' => $id, 'action' => $force ? 'deleted' : 'trashed' ] : new WP_Error( 'delete_failed', 'Could not delete attachment.' );
	}

	/** Max bytes we'll ship to the relay per image — stays under Vercel's 4.5MB request-body cap. */
	const RELAY_MAX_BYTES = 4194304; // 4 MB

	public static function optimize( array $args ) {
		$max_width          = max( 64, min( 8000, (int) ( $args['max_width'] ?? 1200 ) ) );
		$quality            = max( 40, min( 100, (int) ( $args['quality'] ?? 82 ) ) );
		$min_bytes          = (int) ( $args['min_size_kb'] ?? 150 ) * 1024;
		$per_call           = max( 1, min( 25, (int) ( $args['per_call'] ?? 25 ) ) );
		$convert_opaque_png = ! isset( $args['convert_opaque_png'] ) ? true : (bool) $args['convert_opaque_png'];

		if ( ! class_exists( 'PCD_PC_Pair' ) || ! PCD_PC_Pair::is_paired() ) {
			return new WP_Error( 'not_paired', 'Conduit is not paired with the relay, so images can\'t be optimized. Pair the site in the PressConduit admin first.' );
		}

		// Resolve target attachment IDs.
		$ids = [];
		if ( ! empty( $args['ids'] ) && is_array( $args['ids'] ) ) {
			$ids = array_map( 'intval', $args['ids'] );
		} else {
			$ids = get_posts( [
				'post_type'      => 'attachment',
				'post_mime_type' => [ 'image/jpeg', 'image/png', 'image/webp' ],
				'post_status'    => 'inherit',
				'posts_per_page' => 200,
				'fields'         => 'ids',
				'orderby'        => 'ID',
				'order'          => 'DESC',
			] );
		}

		// Build the candidate list: images whose original file is over the
		// size threshold. Stop collecting once we have enough for this call.
		$candidates = [];
		$skipped_too_large = [];
		foreach ( $ids as $id ) {
			$path = get_attached_file( $id );
			if ( ! $path || ! file_exists( $path ) ) continue;
			$mime = get_post_mime_type( $id );
			if ( ! in_array( $mime, [ 'image/jpeg', 'image/png', 'image/webp' ], true ) ) continue;
			$size = filesize( $path );
			if ( $size < $min_bytes ) continue;
			if ( $size > self::RELAY_MAX_BYTES ) {
				$skipped_too_large[] = [ 'id' => $id, 'kb' => round( $size / 1024 ) ];
				continue;
			}
			$candidates[] = [ 'id' => $id, 'path' => $path, 'mime' => $mime, 'bytes' => $size ];
			if ( count( $candidates ) >= $per_call ) break;
		}

		if ( ! $candidates ) {
			$msg = "No images over {$args['min_size_kb']}KB (and under 4MB) found to optimize.";
			$out = [ 'optimized' => 0, 'message' => $msg ];
			if ( $skipped_too_large ) {
				$out['skipped_too_large'] = $skipped_too_large;
				$out['note'] = 'Some images exceed the relay\'s 4MB request limit and were skipped.';
			}
			return $out;
		}

		// Two-phase: preview before applying (this overwrites originals).
		if ( empty( $args['confirm'] ) ) {
			$total = 0;
			$preview = [];
			foreach ( $candidates as $c ) {
				$total += $c['bytes'];
				$dims = @getimagesize( $c['path'] );
				$preview[] = [
					'id'           => $c['id'],
					'current_kb'   => round( $c['bytes'] / 1024 ),
					'width'        => $dims ? $dims[0] : null,
					'will_resize'  => $dims && $dims[0] > $max_width,
				];
			}
			$png_note = $convert_opaque_png
				? ' Opaque (non-transparent) PNGs will be converted to JPEG for major size savings — references to their old .png URL are auto-migrated to .jpg across post_content, options, and postmeta (serialization-safe). Transparent PNGs stay as PNG.'
				: ' All formats preserved exactly (convert_opaque_png=false).';
			$out = [
				'requires_confirmation' => true,
				'count'         => count( $candidates ),
				'total_kb'      => round( $total / 1024 ),
				'plan'          => "Resize anything wider than {$max_width}px down to it, then re-compress on the Conduit relay (sharp) at quality {$quality}." . $png_note . ' Overwrites originals (lossy, not undoable) and regenerates thumbnails.',
				'images'        => $preview,
				'message'       => 'Re-call with confirm:true to optimize these. Run again afterward for the next batch.',
			];
			if ( $skipped_too_large ) {
				$out['skipped_too_large'] = $skipped_too_large;
			}
			return $out;
		}

		if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
			require_once ABSPATH . 'wp-admin/includes/image.php';
		}

		$results       = [];
		$saved_total   = 0;
		$before_total  = 0;
		$after_total   = 0;
		foreach ( $candidates as $c ) {
			$before = $c['bytes'];

			$relay = self::relay_optimize( $c['path'], $max_width, $quality, $convert_opaque_png );
			if ( is_wp_error( $relay ) ) {
				$results[] = [ 'id' => $c['id'], 'status' => 'error', 'error' => $relay->get_error_message() ];
				continue;
			}

			$new_bytes = $relay['bytes'];
			$after     = strlen( $new_bytes );

			// Relay couldn't beat the original — leave the file untouched.
			// (Don't skip if format changed: a PNG→JPEG conversion can yield
			// slightly bigger bytes for tiny images but the format change is
			// still desirable. Only skip when format is unchanged.)
			if ( ! $relay['format_changed'] && $after >= $before ) {
				$before_total += $before;
				$after_total  += $before;
				$results[] = [
					'id'        => $c['id'],
					'status'    => 'skipped',
					'reason'    => 'already_optimal',
					'before_kb' => round( $before / 1024 ),
				];
				continue;
			}

			if ( $relay['format_changed'] ) {
				$changed = self::handle_format_change(
					(int) $c['id'],
					$c['path'],
					$c['mime'],
					$new_bytes,
					$relay['output_format'],
					$relay['content_type']
				);
				if ( is_wp_error( $changed ) ) {
					$results[] = [ 'id' => $c['id'], 'status' => 'error', 'error' => $changed->get_error_message() ];
					continue;
				}
				$before_total += $before;
				$after_total  += $changed['after'];
				$saved_total  += max( 0, $before - $changed['after'] );
				$results[] = [
					'id'             => $c['id'],
					'status'         => 'ok',
					'before_kb'      => round( $before / 1024 ),
					'after_kb'       => round( $changed['after'] / 1024 ),
					'saved_kb'       => round( max( 0, $before - $changed['after'] ) / 1024 ),
					'converted_from' => $relay['input_format'],
					'converted_to'   => $relay['output_format'],
					'old_url'        => $changed['old_url'],
					'new_url'        => $changed['new_url'],
					'urls_migrated'  => $changed['urls_migrated'],
				];
				continue;
			}

			// Format unchanged — atomic in-place replace + thumbnail regen.
			$written = self::write_optimized_original( $c['path'], $c['mime'], $new_bytes );
			if ( is_wp_error( $written ) ) {
				$results[] = [ 'id' => $c['id'], 'status' => 'error', 'error' => $written->get_error_message() ];
				continue;
			}
			clearstatcache( true, $c['path'] );
			$meta = wp_generate_attachment_metadata( $c['id'], $c['path'] );
			if ( ! is_wp_error( $meta ) && $meta ) {
				wp_update_attachment_metadata( $c['id'], $meta );
			}

			$after = file_exists( $c['path'] ) ? filesize( $c['path'] ) : $after;
			$before_total += $before;
			$after_total  += $after;
			$saved_total  += max( 0, $before - $after );
			$results[] = [
				'id'        => $c['id'],
				'status'    => 'ok',
				'before_kb' => round( $before / 1024 ),
				'after_kb'  => round( $after / 1024 ),
				'saved_kb'  => round( max( 0, $before - $after ) / 1024 ),
			];
		}

		$out = [
			'optimized'      => count( array_filter( $results, function ( $r ) { return ( $r['status'] ?? '' ) === 'ok'; } ) ),
			'before_kb'      => round( $before_total / 1024 ),
			'after_kb'       => round( $after_total / 1024 ),
			'saved_kb'       => round( $saved_total / 1024 ),
			'saved_percent'  => $before_total ? round( 100 * $saved_total / $before_total ) : 0,
			'engine'         => 'relay (sharp)',
			'results'        => $results,
			'note'           => 'Run again to process the next batch if more images remain over the size threshold.',
		];
		if ( $skipped_too_large ) {
			$out['skipped_too_large'] = $skipped_too_large;
		}
		return $out;
	}

	/**
	 * Ship one image's bytes to the relay's /optimize endpoint. The relay
	 * compresses with sharp (libvips) and returns the optimized bytes —
	 * possibly in a DIFFERENT format than the input (opaque PNG → JPEG when
	 * convert_opaque_png is true). Response headers tell us the new format.
	 *
	 * @return array|WP_Error On success: {
	 *   bytes,            string  — optimized image bytes
	 *   input_format,     string  — sharp's name for the source format (jpeg/png/webp)
	 *   output_format,    string  — same shape, possibly different from input
	 *   format_changed,   bool    — true when input_format !== output_format
	 *   content_type,     string  — e.g. image/jpeg
	 * }
	 */
	private static function relay_optimize( string $path, int $max_width, int $quality, bool $convert_opaque_png = true ) {
		$backend = PCD_PC_Pair::backend_url();
		if ( ! $backend ) {
			return new WP_Error( 'no_backend', 'Relay backend URL is not configured.' );
		}

		$bytes = file_get_contents( $path );
		if ( false === $bytes || '' === $bytes ) {
			return new WP_Error( 'read_failed', 'Could not read image file.' );
		}

		$url = add_query_arg(
			[
				'max_width'          => $max_width,
				'quality'            => $quality,
				'convert_opaque_png' => $convert_opaque_png ? '1' : '0',
			],
			$backend . '/optimize'
		);

		$response = wp_remote_post( $url, [
			'headers' => [
				'Content-Type'      => 'application/octet-stream',
				'Accept'            => 'application/octet-stream',
				'X-Conduit-Customer'=> PCD_PC_Pair::customer_id(),
				'X-Conduit-Site'    => site_url(),
				'X-Conduit-Token'   => PCD_PC_Auth::get_token(),
			],
			'body'    => $bytes,
			'timeout' => 30,
		] );

		if ( is_wp_error( $response ) ) {
			return new WP_Error( 'relay_unreachable', 'Relay request failed: ' . $response->get_error_message() );
		}

		$status = (int) wp_remote_retrieve_response_code( $response );
		$body   = wp_remote_retrieve_body( $response );

		if ( 200 !== $status ) {
			$decoded = json_decode( $body, true );
			$err = is_array( $decoded ) && isset( $decoded['error'] ) ? $decoded['error'] : ( 'HTTP ' . $status );
			return new WP_Error( 'relay_error', 'Relay declined to optimize: ' . $err );
		}

		if ( '' === $body ) {
			return new WP_Error( 'relay_empty', 'Relay returned an empty image.' );
		}

		return [
			'bytes'          => $body,
			'input_format'   => (string) wp_remote_retrieve_header( $response, 'x-input-format' ),
			'output_format'  => (string) wp_remote_retrieve_header( $response, 'x-output-format' ),
			'format_changed' => '1' === (string) wp_remote_retrieve_header( $response, 'x-format-changed' ),
			'content_type'   => (string) wp_remote_retrieve_header( $response, 'content-type' ),
		];
	}

	/**
	 * Validate the relay's bytes are a real image of the expected type, then
	 * atomically replace the original. We write to a temp file and verify
	 * before overwriting so a bad response can never corrupt the original.
	 *
	 * @return true|WP_Error
	 */
	private static function write_optimized_original( string $path, string $mime, string $bytes ) {
		$tmp = $path . '.dcopt-' . wp_generate_password( 8, false );
		if ( false === file_put_contents( $tmp, $bytes ) ) {
			return new WP_Error( 'write_failed', 'Could not write temp file.' );
		}

		$info = @getimagesize( $tmp );
		if ( ! $info || empty( $info['mime'] ) || $info['mime'] !== $mime ) {
			wp_delete_file($tmp );
			return new WP_Error( 'bad_image', 'Relay returned data that is not a valid ' . $mime . ' image.' );
		}

		global $wp_filesystem;
		if ( ! $wp_filesystem ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
			WP_Filesystem();
		}
		if ( ! $wp_filesystem || ! $wp_filesystem->move( $tmp, $path, true ) ) {
			wp_delete_file($tmp );
			return new WP_Error( 'replace_failed', 'Could not replace the original file.' );
		}

		return true;
	}

	/**
	 * Handle the case where the optimizer changed the output format (e.g.
	 * opaque PNG → JPEG). Writes the new file at a new extension, updates the
	 * WordPress attachment record, regenerates thumbnails, then migrates every
	 * reference to the OLD URL across post_content, options, and postmeta
	 * (with serialization safety so Bricks layouts / plugin settings are not
	 * corrupted). Finally cleans up the old original + old thumbnails on disk.
	 *
	 * @return array|WP_Error On success: { after, old_url, new_url, urls_migrated }.
	 */
	private static function handle_format_change( int $id, string $old_path, string $old_mime, string $bytes, string $new_format, string $new_content_type ) {
		if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
			require_once ABSPATH . 'wp-admin/includes/image.php';
		}

		$dir         = dirname( $old_path );
		$basename    = pathinfo( $old_path, PATHINFO_FILENAME );
		$new_ext     = ( 'jpeg' === $new_format ) ? 'jpg' : $new_format;
		$new_path    = $dir . '/' . $basename . '.' . $new_ext;

		// Don't clobber an unrelated existing file at the target path.
		if ( $new_path !== $old_path && file_exists( $new_path ) ) {
			$new_path = $dir . '/' . $basename . '-opt.' . $new_ext;
		}

		// Capture old state BEFORE any mutation — we need it for cleanup + url migration.
		$old_url        = wp_get_attachment_url( $id );
		$old_meta       = wp_get_attachment_metadata( $id );
		$old_thumb_paths = [];
		if ( is_array( $old_meta ) && is_array( $old_meta['sizes'] ?? null ) ) {
			foreach ( $old_meta['sizes'] as $size_info ) {
				if ( ! empty( $size_info['file'] ) ) {
					$old_thumb_paths[] = $dir . '/' . $size_info['file'];
				}
			}
		}

		// Write the new file with validation against truncated/garbage responses.
		$tmp = $new_path . '.dcopt-' . wp_generate_password( 8, false );
		if ( false === file_put_contents( $tmp, $bytes ) ) {
			return new WP_Error( 'write_failed', 'Could not write new file.' );
		}
		$info = @getimagesize( $tmp );
		if ( ! $info || empty( $info['mime'] ) ) {
			wp_delete_file($tmp );
			return new WP_Error( 'bad_image', 'Relay returned data that is not a valid image.' );
		}
		// Cross-check the actual file bytes' mime against the expected content type.
		$expected_mime = trim( explode( ';', $new_content_type )[0] );
		if ( $expected_mime && $info['mime'] !== $expected_mime ) {
			wp_delete_file($tmp );
			return new WP_Error( 'mime_mismatch', "Relay said {$expected_mime} but file is {$info['mime']}." );
		}
		global $wp_filesystem;
		if ( ! $wp_filesystem ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
			WP_Filesystem();
		}
		if ( ! $wp_filesystem || ! $wp_filesystem->move( $tmp, $new_path, true ) ) {
			wp_delete_file($tmp );
			return new WP_Error( 'rename_failed', 'Could not place the new file at ' . $new_path . '.' );
		}

		// Snapshot the attachment record so the user can revert if migration goes sideways.
		if ( class_exists( 'PCD_PC_Snapshot' ) ) {
			PCD_PC_Snapshot::capture( 'post', (string) $id, [
				'ID'              => $id,
				'post_mime_type'  => $old_mime,
				'_wp_attached_file' => get_post_meta( $id, '_wp_attached_file', true ),
				'_wp_attachment_metadata' => $old_meta,
			], "Attachment #{$id} before format-change ({$old_mime} → {$info['mime']})" );
		}

		// Update WordPress's record: file path + mime type, then regenerate thumbnails.
		update_attached_file( $id, $new_path );
		wp_update_post( [ 'ID' => $id, 'post_mime_type' => $info['mime'] ] );
		clearstatcache( true, $new_path );
		$new_meta = wp_generate_attachment_metadata( $id, $new_path );
		if ( ! is_wp_error( $new_meta ) && $new_meta ) {
			wp_update_attachment_metadata( $id, $new_meta );
		} else {
			$new_meta = is_array( $new_meta ) ? $new_meta : [];
		}

		$new_url = wp_get_attachment_url( $id );

		// Migrate references: the full-size URL, plus every sized-thumbnail
		// variant whose filename changed (image-300x150.png → image-300x150.jpg).
		$urls_migrated = 0;
		if ( $old_url && $new_url && $old_url !== $new_url ) {
			$urls_migrated += self::safe_replace_in_db( $old_url, $new_url );
		}
		if ( is_array( $old_meta['sizes'] ?? null ) && is_array( $new_meta['sizes'] ?? null ) ) {
			$uploads = wp_upload_dir();
			$rel_dir = trailingslashit( dirname( str_replace( $uploads['basedir'], $uploads['baseurl'], $old_path ) ) );
			foreach ( $old_meta['sizes'] as $size_name => $old_size ) {
				if ( empty( $old_size['file'] ) ) continue;
				$new_size = $new_meta['sizes'][ $size_name ] ?? null;
				if ( empty( $new_size['file'] ) ) continue;
				$old_size_url = $rel_dir . $old_size['file'];
				$new_size_url = $rel_dir . $new_size['file'];
				if ( $old_size_url !== $new_size_url ) {
					$urls_migrated += self::safe_replace_in_db( $old_size_url, $new_size_url );
				}
			}
		}

		// Clean up old files on disk — the old original and any old-format
		// thumbnails NOT being reused as new thumbnails.
		$new_thumb_paths = [];
		if ( is_array( $new_meta['sizes'] ?? null ) ) {
			foreach ( $new_meta['sizes'] as $size_info ) {
				if ( ! empty( $size_info['file'] ) ) {
					$new_thumb_paths[] = $dir . '/' . $size_info['file'];
				}
			}
		}
		if ( $old_path !== $new_path && file_exists( $old_path ) ) {
			wp_delete_file($old_path );
		}
		foreach ( $old_thumb_paths as $p ) {
			if ( $p && ! in_array( $p, $new_thumb_paths, true ) && file_exists( $p ) ) {
				wp_delete_file($p );
			}
		}

		$after = file_exists( $new_path ) ? filesize( $new_path ) : strlen( $bytes );

		return [
			'after'         => $after,
			'old_url'       => $old_url,
			'new_url'       => $new_url,
			'urls_migrated' => $urls_migrated,
		];
	}

	/**
	 * Safely replace every occurrence of $old with $new across the most
	 * common locations URLs are referenced: wp_posts.post_content (plain
	 * text), wp_options.option_value (possibly serialized), and
	 * wp_postmeta.meta_value (possibly serialized). For serialized values
	 * we walk the data structure so the byte-length prefixes stay valid —
	 * the failure mode of naive str_replace on serialized data is silently
	 * corrupted plugin settings, which is unacceptable.
	 *
	 * Returns the number of database rows that were changed.
	 */
	private static function safe_replace_in_db( string $old, string $new ): int {
		global $wpdb;
		if ( '' === $old || $old === $new ) return 0;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery -- one-off URL migration across core tables after a media format change; values use prepare(), results are not cacheable.
		$changed = 0;
		$like    = '%' . $wpdb->esc_like( $old ) . '%';

		// 1. wp_posts.post_content — plain text. Single bulk REPLACE.
		$rows = $wpdb->query( $wpdb->prepare(
			"UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) WHERE post_content LIKE %s",
			$old, $new, $like
		) );
		if ( $rows ) $changed += (int) $rows;

		// 2. wp_options.option_value — may be serialized; walk safely.
		$option_rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT option_id, option_value FROM {$wpdb->options} WHERE option_value LIKE %s",
			$like
		) );
		foreach ( (array) $option_rows as $row ) {
			$migrated = self::deep_serialize_safe_replace( $row->option_value, $old, $new );
			if ( $migrated !== $row->option_value ) {
				$wpdb->update( $wpdb->options, [ 'option_value' => $migrated ], [ 'option_id' => $row->option_id ] );
				$changed++;
			}
		}

		// 3. wp_postmeta.meta_value — may be serialized; walk safely.
		$meta_rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT meta_id, meta_value FROM {$wpdb->postmeta} WHERE meta_value LIKE %s",
			$like
		) );
		foreach ( (array) $meta_rows as $row ) {
			$migrated = self::deep_serialize_safe_replace( $row->meta_value, $old, $new );
			if ( $migrated !== $row->meta_value ) {
				$wpdb->update( $wpdb->postmeta, [ 'meta_value' => $migrated ], [ 'meta_id' => $row->meta_id ] );
				$changed++;
			}
		}

		// phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery
		return $changed;
	}

	/**
	 * If $value is a PHP-serialized string, unserialize it, walk every leaf
	 * string replacing $old with $new, and re-serialize. Otherwise treat as
	 * plain string. This avoids the classic bug where str_replace on a
	 * serialized string with embedded byte-length prefixes silently corrupts
	 * the data (because the length prefix no longer matches the new content).
	 */
	private static function deep_serialize_safe_replace( $value, string $old, string $new ) {
		if ( ! is_string( $value ) ) return $value;
		if ( strpos( $value, $old ) === false ) return $value;

		$unserialized = @unserialize( $value );
		if ( false !== $unserialized || 'b:0;' === $value ) {
			$walked = self::walk_replace_strings( $unserialized, $old, $new );
			return serialize( $walked );
		}

		return str_replace( $old, $new, $value );
	}

	/** Recursively str_replace every string leaf in a data structure. */
	private static function walk_replace_strings( $data, string $old, string $new ) {
		if ( is_array( $data ) ) {
			$out = [];
			foreach ( $data as $k => $v ) {
				$out[ $k ] = self::walk_replace_strings( $v, $old, $new );
			}
			return $out;
		}
		if ( $data instanceof \stdClass ) {
			$out = new \stdClass();
			foreach ( get_object_vars( $data ) as $k => $v ) {
				$out->$k = self::walk_replace_strings( $v, $old, $new );
			}
			return $out;
		}
		// Other class instances: leave untouched — we'd have to reconstruct them
		// and could break private state.
		if ( is_object( $data ) ) return $data;
		if ( is_string( $data ) ) return str_replace( $old, $new, $data );
		return $data;
	}
}
