<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Srcset built from Bootstrap breakpoints and the photograph's own width.
 *
 * WHAT IT SOLVES. WordPress's native srcset dumps every intermediate size it happens to have —
 * 150, 300, 768, 1024, 1536 — whether or not they match how the image is shown. A 800px hero still
 * lists a 768 that is almost the original, and a 400px card still lists a 1536. The browser then
 * picks with a `sizes` of `100vw` that is a guess, not a measurement.
 *
 * THE RULE, the same one the Farmerbit Image component uses:
 *
 *  1. the candidates are Bootstrap's widths: 360, 576, 768, 992, 1200, 1440;
 *  2. a candidate is kept only when the photograph is meaningfully larger (`width > bp * 1.2`) —
 *     a 800px file does not pay for a 768 that nobody can tell apart;
 *  3. the original (or the variant actually in `src`) is always the last entry;
 *  4. `sizes` is the cascade of those widths: `(max-width: 576px) 576px, …, 1600px`.
 *
 * FILES. The HTML request does not encode. A needed variant that is already on disk is listed; a
 * miss is queued and built after the response (or on upload, while the person who chose the photo
 * is already waiting). Listing a URL that 404s would be worse than listing nothing: the browser
 * would throw away the whole srcset.
 *
 * WHAT IT DOES NOT DO. It does not crop. It does not register `add_image_size` globally — those
 * sizes would leak into every theme `<img>` via WordPress's own srcset. It does not invent
 * dimensions for a URL outside uploads.
 */
class AIPB_Srcset {

	/** Bootstrap's container / breakpoint widths, extra-small through extra-large. */
	const BREAKPOINTS = array( 360, 576, 768, 992, 1200, 1440 );

	/**
	 * A variant has to be this much smaller than the source or it is not worth a file.
	 *
	 * 800 > 768 * 1.2 is false: the 768 is skipped. 1600 > 1200 * 1.2 is true: the 1200 is kept.
	 */
	const FACTOR = 1.2;

	/** How many `save()` calls one background worker is allowed. */
	const BUDGET = 8;

	/** Cron hook that drains the persisted miss queue. */
	const CRON = 'aipb_srcset_generate';

	/** Option that holds source paths waiting for a host without fastcgi_finish_request. */
	const OPTION = 'aipb_srcset_queue';

	/** Source files that still need variants, keyed by path. */
	protected static $queue = array();

	/** True while a worker is draining the queue: shutdown must not re-enter. */
	protected static $draining = false;

	public static function init() {
		// Before WebP: the siblings it converts have to exist, or the first upload would encode
		// the original and miss every breakpoint file this filter is about to create.
		add_filter( 'wp_generate_attachment_metadata', array( __CLASS__, 'on_upload' ), 9, 2 );
		add_action( 'shutdown', array( __CLASS__, 'on_shutdown' ), 90 );
		add_action( self::CRON, array( __CLASS__, 'cron' ) );
	}

	/**
	 * Whether Aura should compose the srcset at all.
	 *
	 * Filter `aipb_srcset` turns the whole feature off: companions fall back to WordPress native.
	 */
	public static function enabled() {
		return (bool) apply_filters( 'aipb_srcset', true );
	}

	/* --------------------------------- Plan --------------------------------- */

	/**
	 * Breakpoint widths worth generating for a photograph of `$max` CSS pixels.
	 *
	 * Does not include `$max` itself: that is the last srcset entry, and it is the file already
	 * in `src`.
	 *
	 * @param int $max
	 * @return list<int>
	 */
	public static function plan( $max ) {
		$max = (int) $max;
		if ( $max < 1 ) {
			return array();
		}

		$factor = (float) apply_filters( 'aipb_srcset_factor', self::FACTOR );
		if ( $factor < 1 ) {
			$factor = self::FACTOR;
		}

		$breakpoints = apply_filters( 'aipb_srcset_breakpoints', self::BREAKPOINTS );
		if ( ! is_array( $breakpoints ) ) {
			$breakpoints = self::BREAKPOINTS;
		}

		$out = array();
		foreach ( $breakpoints as $bp ) {
			$bp = (int) $bp;
			if ( $bp < 1 || $bp >= $max ) {
				continue;
			}
			if ( $max > $bp * $factor ) {
				$out[] = $bp;
			}
		}

		return array_values( array_unique( $out ) );
	}

	/**
	 * The `sizes` attribute for a plan: each generated width as a max-width slot, then the source.
	 *
	 * @param list<int> $generated
	 * @param int       $max
	 * @return string
	 */
	public static function sizes_attr( $generated, $max ) {
		$max = (int) $max;
		$out = array();
		foreach ( (array) $generated as $w ) {
			$w = (int) $w;
			if ( $w > 0 ) {
				$out[] = '(max-width: ' . $w . 'px) ' . $w . 'px';
			}
		}
		if ( $max > 0 ) {
			$out[] = $max . 'px';
		}

		return implode( ', ', $out );
	}

	/**
	 * The `srcset` attribute from `{url, width}` items, in width order, no duplicates.
	 *
	 * @param list<array{url:string,width:int}> $items
	 * @return string
	 */
	public static function srcset_attr( $items ) {
		$by_width = array();
		foreach ( (array) $items as $item ) {
			if ( empty( $item['url'] ) || empty( $item['width'] ) ) {
				continue;
			}
			$w = (int) $item['width'];
			if ( $w < 1 ) {
				continue;
			}
			$by_width[ $w ] = trim( (string) $item['url'] );
		}
		ksort( $by_width, SORT_NUMERIC );

		$parts = array();
		foreach ( $by_width as $w => $url ) {
			$parts[] = $url . ' ' . $w . 'w';
		}

		return implode( ', ', $parts );
	}

	/**
	 * The filename WordPress (and this class) give a downscale: `hero.jpg` → `hero-576x324.jpg`.
	 *
	 * Public so the naming is testable without a disk.
	 *
	 * @param string $filename basename, with extension
	 * @param int    $width
	 * @param int    $height
	 * @return string
	 */
	public static function variant_name( $filename, $width, $height ) {
		$ext  = (string) pathinfo( $filename, PATHINFO_EXTENSION );
		$name = (string) pathinfo( $filename, PATHINFO_FILENAME );
		$name = preg_replace( '/-\d+x\d+$/', '', $name );

		return $name . '-' . (int) $width . 'x' . (int) $height . ( '' !== $ext ? '.' . $ext : '' );
	}

	/* --------------------------------- Public -------------------------------- */

	/**
	 * Srcset string for an attachment, or '' when there is nothing to say.
	 *
	 * @param int $attachment_id
	 * @param int $max_width  ceiling in pixels; 0 means the original's width
	 * @return string
	 */
	public static function srcset( $attachment_id, $max_width = 0 ) {
		$built = self::for_attachment( $attachment_id, $max_width );

		return isset( $built['srcset'] ) ? $built['srcset'] : '';
	}

	/**
	 * Sizes string for an attachment, or '' when there is nothing to say.
	 *
	 * @param int $attachment_id
	 * @param int $max_width
	 * @return string
	 */
	public static function sizes( $attachment_id, $max_width = 0 ) {
		$built = self::for_attachment( $attachment_id, $max_width );

		return isset( $built['sizes'] ) ? $built['sizes'] : '';
	}

	/**
	 * Both attributes for an attachment.
	 *
	 * @param int $attachment_id
	 * @param int $max_width
	 * @return array{srcset:string,sizes:string}
	 */
	public static function for_attachment( $attachment_id, $max_width = 0 ) {
		$empty = array( 'srcset' => '', 'sizes' => '' );
		if ( ! self::enabled() ) {
			return $empty;
		}

		$attachment_id = (int) $attachment_id;
		if ( $attachment_id < 1 ) {
			return $empty;
		}

		$meta = function_exists( 'wp_get_attachment_metadata' ) ? wp_get_attachment_metadata( $attachment_id ) : false;
		if ( ! is_array( $meta ) || empty( $meta['width'] ) ) {
			return $empty;
		}

		$orig_w = (int) $meta['width'];
		$orig_h = ! empty( $meta['height'] ) ? (int) $meta['height'] : 0;
		if ( $orig_w < 1 || $orig_h < 1 ) {
			return $empty;
		}

		$file = function_exists( 'get_attached_file' ) ? get_attached_file( $attachment_id ) : '';
		$url  = function_exists( 'wp_get_attachment_url' ) ? wp_get_attachment_url( $attachment_id ) : '';
		if ( ! $file || ! $url || ! self::is_raster( $file ) ) {
			return $empty;
		}

		return self::build( $file, $url, $orig_w, $orig_h, $max_width, is_array( $meta ) ? $meta : array() );
	}

	/**
	 * Both attributes from the URL an `<img>` already points at.
	 *
	 * `$max_width` is the ceiling: the `width` attribute when it describes the file, or 0 to take
	 * the original. A variant in the URL (`hero-1200x800.jpg`) caps at 1200, so a card that already
	 * chose a smaller file does not list a 1440 the browser would never need.
	 *
	 * @param string $src
	 * @param int    $max_width
	 * @return array{srcset:string,sizes:string}
	 */
	public static function for_src( $src, $max_width = 0 ) {
		$empty = array( 'srcset' => '', 'sizes' => '' );
		if ( ! self::enabled() ) {
			return $empty;
		}

		$src = html_entity_decode( trim( (string) $src ), ENT_QUOTES );
		if ( '' === $src || 0 === strpos( $src, 'data:' ) || preg_match( '/\.svg(\?|#|$)/i', $src ) ) {
			return $empty;
		}

		if ( preg_match( '/-(\d+)x(\d+)\.[a-z0-9]+(\?|#|$)/i', $src, $d ) ) {
			$from_name = (int) $d[1];
			if ( $from_name > 0 && ( $max_width < 1 || $from_name < $max_width ) ) {
				$max_width = $from_name;
			}
		}

		if ( class_exists( 'AIPB_Template_Engine' ) ) {
			$id = AIPB_Template_Engine::attachment_id( $src );
			if ( $id ) {
				$built = self::for_attachment( $id, $max_width );
				if ( '' !== $built['srcset'] ) {
					return self::with_src( $built, $src, $max_width );
				}
			}
		}

		$located = self::locate( $src );
		if ( ! $located ) {
			return $empty;
		}

		$orig_w = 0;
		$orig_h = 0;
		$size   = @getimagesize( $located['path'] );
		if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
			$orig_w = (int) $size[0];
			$orig_h = (int) $size[1];
		}
		if ( $orig_w < 1 || $orig_h < 1 ) {
			return $empty;
		}

		return self::with_src(
			self::build( $located['path'], $located['url'], $orig_w, $orig_h, $max_width, array() ),
			$src,
			$max_width
		);
	}

	/**
	 * Extra attributes for an `<img>` that has no srcset yet.
	 *
	 * Called from {@see AIPB_Render::enhance_images()} with the tag's attributes PLUS any width
	 * just added, so the ceiling is the file's real width rather than a guess.
	 *
	 * @param string $attrs
	 * @return string leading-space attributes, or ''
	 */
	public static function img_attrs( $attrs ) {
		if ( ! self::enabled() || preg_match( '/\bsrcset\s*=/i', $attrs ) ) {
			return '';
		}
		if ( ! preg_match( '/\bsrc\s*=\s*("([^"]*)"|\'([^\']*)\')/i', $attrs, $m ) ) {
			return '';
		}
		$src = html_entity_decode( trim( isset( $m[3] ) && '' !== $m[3] ? $m[3] : $m[2] ), ENT_QUOTES );

		$max = 0;
		if ( preg_match( '/\bwidth\s*=\s*("([^"]*)"|\'([^\']*)\')/i', $attrs, $w ) ) {
			$max = (int) ( isset( $w[3] ) && '' !== $w[3] ? $w[3] : $w[2] );
		}

		$built = self::for_src( $src, $max );
		if ( '' === $built['srcset'] ) {
			return '';
		}

		$out = ' srcset="' . esc_attr( $built['srcset'] ) . '"';
		if ( ! preg_match( '/\bsizes\s*=/i', $attrs ) && '' !== $built['sizes'] ) {
			$out .= ' sizes="' . esc_attr( $built['sizes'] ) . '"';
		}

		return $out;
	}

	/* -------------------------------- Upload -------------------------------- */

	/**
	 * Breakpoint files of a new photograph, written into metadata so deletion cleans them up.
	 *
	 * @param array $metadata
	 * @param int   $attachment_id
	 * @return array
	 */
	public static function on_upload( $metadata, $attachment_id ) {
		if ( ! self::enabled() || ! is_array( $metadata ) || empty( $metadata['width'] ) || empty( $metadata['height'] ) ) {
			return $metadata;
		}
		if ( $attachment_id && function_exists( 'wp_attachment_is_image' ) && ! wp_attachment_is_image( $attachment_id ) ) {
			return $metadata;
		}

		$file = '';
		if ( $attachment_id && function_exists( 'get_attached_file' ) ) {
			$file = (string) get_attached_file( (int) $attachment_id );
		}
		if ( ( '' === $file || ! is_file( $file ) ) && ! empty( $metadata['file'] ) && function_exists( 'wp_upload_dir' ) ) {
			$dir  = wp_upload_dir();
			$file = ! empty( $dir['basedir'] ) ? untrailingslashit( $dir['basedir'] ) . '/' . ltrim( str_replace( '\\', '/', (string) $metadata['file'] ), '/' ) : '';
		}
		if ( '' === $file || ! is_file( $file ) || ! self::is_raster( $file ) ) {
			return $metadata;
		}

		$orig_w = (int) $metadata['width'];
		$orig_h = (int) $metadata['height'];
		if ( empty( $metadata['sizes'] ) || ! is_array( $metadata['sizes'] ) ) {
			$metadata['sizes'] = array();
		}

		foreach ( self::plan( $orig_w ) as $w ) {
			$entry = self::make( $file, $w, $orig_w, $orig_h, true );
			if ( $entry ) {
				$metadata['sizes'][ 'aipb-' . $w ] = $entry;
			}
		}

		return $metadata;
	}

	/** After the response: build the misses, or hand them to cron. */
	public static function on_shutdown() {
		if ( ! self::$queue || ! self::enabled() || self::$draining ) {
			return;
		}

		if ( function_exists( 'fastcgi_finish_request' ) || function_exists( 'litespeed_finish_request' ) ) {
			self::$draining = true;
			if ( function_exists( 'session_write_close' ) ) {
				session_write_close();
			}
			while ( ob_get_level() > 0 ) {
				ob_end_flush();
			}
			ignore_user_abort( true );
			if ( function_exists( 'fastcgi_finish_request' ) ) {
				fastcgi_finish_request();
			} else {
				litespeed_finish_request();
			}
			self::run_queue();
			if ( self::$queue ) {
				self::persist_and_schedule();
			}

			return;
		}

		self::persist_and_schedule();
	}

	public static function cron() {
		$stored = get_option( self::OPTION, array() );
		delete_option( self::OPTION );
		if ( ! is_array( $stored ) || ! $stored ) {
			return;
		}

		self::$draining = true;
		foreach ( array_keys( $stored ) as $path ) {
			self::$queue[ $path ] = true;
		}
		self::run_queue();
		if ( self::$queue ) {
			self::persist_and_schedule();
		}
	}

	/** Paths waiting to be resized, for tests. */
	public static function queued() {
		return array_keys( self::$queue );
	}

	/** Empties the in-memory queue. Tests, and nothing else. */
	public static function reset_queue() {
		self::$queue    = array();
		self::$draining = false;
	}

	/* -------------------------------- Internals ------------------------------ */

	/**
	 * Compose srcset/sizes from a file that is already on disk.
	 *
	 * Missing variants are queued, never listed. The source itself is always listed at `$max`.
	 *
	 * @param string $file
	 * @param string $url
	 * @param int    $orig_w
	 * @param int    $orig_h
	 * @param int    $max_width
	 * @param array  $meta
	 * @return array{srcset:string,sizes:string}
	 */
	protected static function build( $file, $url, $orig_w, $orig_h, $max_width, $meta ) {
		$orig_w = (int) $orig_w;
		$orig_h = (int) $orig_h;
		$max    = (int) $max_width;
		if ( $max < 1 || $max > $orig_w ) {
			$max = $orig_w;
		}

		$file = self::normalize( $file );
		$url  = preg_replace( '/[?#].*$/', '', (string) $url );
		$dir  = dirname( $url );

		$generated = self::plan( $max );
		$items     = array();
		foreach ( $generated as $w ) {
			$found = self::find_variant( $file, $url, $dir, $w, $orig_w, $orig_h, $meta );
			if ( $found ) {
				$items[] = $found;
			} else {
				self::enqueue( $file );
			}
		}

		$items[] = array(
			'url'   => $url,
			'width' => $max,
		);

		return array(
			'srcset' => self::srcset_attr( $items ),
			'sizes'  => self::sizes_attr( $generated, $max ),
		);
	}

	/**
	 * Prefer the URL the `<img>` already uses as the last srcset entry, so `src` and srcset agree.
	 *
	 * @param array{srcset:string,sizes:string} $built
	 * @param string                            $src
	 * @param int                               $max_width
	 * @return array{srcset:string,sizes:string}
	 */
	protected static function with_src( $built, $src, $max_width ) {
		$src = preg_replace( '/[?#].*$/', '', (string) $src );
		if ( '' === $built['srcset'] || '' === $src ) {
			return $built;
		}

		$items = array();
		foreach ( explode( ',', $built['srcset'] ) as $part ) {
			$part = trim( $part );
			if ( preg_match( '/^(\S+)\s+(\d+)w$/', $part, $m ) ) {
				$items[] = array(
					'url'   => $m[1],
					'width' => (int) $m[2],
				);
			}
		}
		if ( ! $items ) {
			return $built;
		}

		$max = (int) $max_width;
		if ( $max < 1 ) {
			$max = (int) $items[ count( $items ) - 1 ]['width'];
		}
		$items[ count( $items ) - 1 ] = array(
			'url'   => $src,
			'width' => $max > 0 ? $max : (int) $items[ count( $items ) - 1 ]['width'],
		);

		$built['srcset'] = self::srcset_attr( $items );

		return $built;
	}

	/**
	 * A variant already on disk, or a metadata size whose box matches the breakpoint.
	 *
	 * @return array{url:string,width:int}|null
	 */
	protected static function find_variant( $file, $url, $dir, $width, $orig_w, $orig_h, $meta ) {
		$height = self::height_for( $width, $orig_w, $orig_h );
		$dest   = self::variant_path( $file, $width, $height );
		if ( $dest && is_file( $dest ) ) {
			return array(
				'url'   => $dir . '/' . basename( $dest ),
				'width' => $width,
			);
		}

		if ( ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) {
			foreach ( $meta['sizes'] as $size ) {
				if ( empty( $size['file'] ) || empty( $size['width'] ) ) {
					continue;
				}
				if ( (int) $size['width'] !== $width ) {
					continue;
				}
				$size_h = ! empty( $size['height'] ) ? (int) $size['height'] : 0;
				if ( $size_h > 0 && abs( $size_h - $height ) > 2 ) {
					continue; // cropped to a different box: not this photograph at this width
				}
				$candidate = dirname( $file ) . '/' . basename( (string) $size['file'] );
				if ( is_file( $candidate ) ) {
					return array(
						'url'   => $dir . '/' . basename( (string) $size['file'] ),
						'width' => $width,
					);
				}
			}
		}

		return null;
	}

	protected static function height_for( $width, $orig_w, $orig_h ) {
		$width  = (int) $width;
		$orig_w = (int) $orig_w;
		$orig_h = (int) $orig_h;
		if ( $width < 1 || $orig_w < 1 || $orig_h < 1 ) {
			return 1;
		}
		$h = (int) round( $orig_h * ( $width / $orig_w ) );

		return $h < 1 ? 1 : $h;
	}

	protected static function variant_path( $file, $width, $height ) {
		$file = self::normalize( $file );
		if ( '' === $file ) {
			return '';
		}

		return dirname( $file ) . '/' . self::variant_name( basename( $file ), $width, $height );
	}

	/**
	 * Writes one downscale. Returns the metadata size entry, or null when it could not.
	 *
	 * @param string $file
	 * @param int    $width
	 * @param int    $orig_w
	 * @param int    $orig_h
	 * @param bool   $encode  true on upload / queue; false would only look, and this method always encodes
	 * @return array{file:string,width:int,height:int,mime-type:string}|null
	 */
	protected static function make( $file, $width, $orig_w, $orig_h, $encode = true ) {
		$height = self::height_for( $width, $orig_w, $orig_h );
		$dest   = self::variant_path( $file, $width, $height );
		if ( '' === $dest ) {
			return null;
		}
		if ( is_file( $dest ) ) {
			return self::size_entry( $dest, $width, $height );
		}
		if ( ! $encode || ! function_exists( 'wp_get_image_editor' ) ) {
			return null;
		}

		$lock_path = $dest . '.lock';
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$lock = fopen( $lock_path, 'c' );
		if ( ! $lock ) {
			return null;
		}
		if ( ! flock( $lock, LOCK_EX | LOCK_NB ) ) {
			fclose( $lock );

			return is_file( $dest ) ? self::size_entry( $dest, $width, $height ) : null;
		}

		$entry = null;
		if ( is_file( $dest ) ) {
			$entry = self::size_entry( $dest, $width, $height );
		} else {
			$editor = wp_get_image_editor( $file );
			if ( ! is_wp_error( $editor ) ) {
				$resized = $editor->resize( $width, $height, false );
				if ( ! is_wp_error( $resized ) ) {
					$saved = $editor->save( $dest );
					if ( ! is_wp_error( $saved ) && is_file( $dest ) ) {
						$entry = self::size_entry( $dest, $width, $height, $saved );
					}
				}
			}
		}

		flock( $lock, LOCK_UN );
		fclose( $lock );
		if ( file_exists( $lock_path ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
			@unlink( $lock_path );
		}

		return $entry;
	}

	protected static function size_entry( $dest, $width, $height, $saved = array() ) {
		$mime = '';
		if ( is_array( $saved ) && ! empty( $saved['mime-type'] ) ) {
			$mime = (string) $saved['mime-type'];
		}

		return array(
			'file'      => basename( $dest ),
			'width'     => (int) $width,
			'height'    => (int) $height,
			'mime-type' => $mime,
		);
	}

	protected static function run_queue() {
		$limit = (int) apply_filters( 'aipb_srcset_budget', self::BUDGET );
		if ( $limit < 1 ) {
			$limit = self::BUDGET;
		}

		$n = 0;
		foreach ( array_keys( self::$queue ) as $path ) {
			if ( $n >= $limit ) {
				break;
			}
			unset( self::$queue[ $path ] );
			$path = self::normalize( $path );
			if ( '' === $path || ! is_file( $path ) ) {
				continue;
			}
			$size = @getimagesize( $path );
			if ( ! is_array( $size ) || empty( $size[0] ) || empty( $size[1] ) ) {
				continue;
			}
			$orig_w = (int) $size[0];
			$orig_h = (int) $size[1];
			foreach ( self::plan( $orig_w ) as $w ) {
				if ( $n >= $limit ) {
					self::$queue[ $path ] = true;
					break;
				}
				$dest = self::variant_path( $path, $w, self::height_for( $w, $orig_w, $orig_h ) );
				if ( $dest && is_file( $dest ) ) {
					continue;
				}
				if ( self::make( $path, $w, $orig_w, $orig_h, true ) ) {
					++$n;
					if ( $dest && class_exists( 'AIPB_WebP' ) && method_exists( 'AIPB_WebP', 'convert' ) ) {
						AIPB_WebP::convert( $dest );
					}
				}
			}
		}
	}

	protected static function persist_and_schedule() {
		$stored = get_option( self::OPTION, array() );
		if ( ! is_array( $stored ) ) {
			$stored = array();
		}
		foreach ( array_keys( self::$queue ) as $path ) {
			$stored[ $path ] = 1;
		}
		self::$queue = array();
		if ( ! $stored ) {
			return;
		}
		update_option( self::OPTION, $stored, false );
		if ( function_exists( 'wp_next_scheduled' ) && ! wp_next_scheduled( self::CRON ) ) {
			wp_schedule_single_event( time(), self::CRON );
		}
		if ( function_exists( 'spawn_cron' ) ) {
			spawn_cron();
		}
	}

	protected static function enqueue( $path ) {
		$path = self::normalize( $path );
		if ( '' !== $path ) {
			self::$queue[ $path ] = true;
		}
	}

	/**
	 * A URL → the file in uploads, or null when it is not ours.
	 *
	 * @param string $url
	 * @return array{path:string,url:string}|null
	 */
	protected static function locate( $url ) {
		$url = html_entity_decode( trim( (string) $url ), ENT_QUOTES );
		if ( '' === $url || 0 === strpos( $url, 'data:' ) ) {
			return null;
		}

		$clean = preg_replace( '/[?#].*$/', '', $url );
		$ext   = strtolower( (string) pathinfo( $clean, PATHINFO_EXTENSION ) );
		if ( ! in_array( $ext, array( 'jpg', 'jpeg', 'png', 'webp' ), true ) ) {
			return null;
		}

		if ( ! function_exists( 'wp_upload_dir' ) ) {
			return null;
		}
		$dir = wp_upload_dir();
		if ( ! is_array( $dir ) || ! empty( $dir['error'] ) || empty( $dir['basedir'] ) || empty( $dir['baseurl'] ) ) {
			return null;
		}

		$basedir = self::normalize( untrailingslashit( $dir['basedir'] ) );
		$baseurl = untrailingslashit( $dir['baseurl'] );

		$path    = '';
		$clean_ns = preg_replace( '#^https?:#i', '', $clean );
		$base_ns  = preg_replace( '#^https?:#i', '', $baseurl );
		if ( 0 === strpos( $clean, $baseurl ) ) {
			$path = $basedir . substr( $clean, strlen( $baseurl ) );
		} elseif ( 0 === strpos( $clean_ns, $base_ns ) ) {
			$path = $basedir . substr( $clean_ns, strlen( $base_ns ) );
		} else {
			$path_part = function_exists( 'wp_parse_url' ) ? (string) wp_parse_url( $clean, PHP_URL_PATH ) : (string) parse_url( $clean, PHP_URL_PATH );
			$base_part = function_exists( 'wp_parse_url' ) ? (string) wp_parse_url( $baseurl, PHP_URL_PATH ) : (string) parse_url( $baseurl, PHP_URL_PATH );
			if ( '' !== $path_part && '' !== $base_part && 0 === strpos( $path_part, $base_part ) ) {
				$path = $basedir . substr( $path_part, strlen( $base_part ) );
			}
		}

		$path = self::normalize( $path );
		if ( '' === $path || ! is_file( $path ) ) {
			return null;
		}

		$real = realpath( $path );
		$root = realpath( $basedir );
		if ( false === $real || false === $root ) {
			return null;
		}
		$real = self::normalize( $real );
		$root = self::normalize( $root );
		if ( 0 !== strpos( $real . '/', $root . '/' ) ) {
			return null;
		}

		return array(
			'path' => $real,
			'url'  => $clean,
		);
	}

	protected static function is_raster( $file ) {
		$ext = strtolower( (string) pathinfo( $file, PATHINFO_EXTENSION ) );

		return in_array( $ext, array( 'jpg', 'jpeg', 'png', 'webp' ), true );
	}

	protected static function normalize( $path ) {
		$path = str_replace( '\\', '/', (string) $path );
		if ( function_exists( 'wp_normalize_path' ) ) {
			$path = wp_normalize_path( $path );
		}

		return $path;
	}
}
