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

/**
 * WebP copies of the photographs that Aura prints, without touching the page's TTFB.
 *
 * WHAT IT SOLVES. A section's <img> points at a JPEG from the Media Library. The browser downloads
 * that JPEG, even when the host could have handed it a WebP half the size. WordPress already knows
 * how to encode WebP (GD/Imagick); what it does not do is put that file next to the original and
 * mention it in the markup. This class does those two things, and it does them in the order that
 * does not make the first visitor wait.
 *
 * THE ORDER:
 *
 *  1. the HTML never encodes. `wrap()` looks on disk: if every sibling of src+srcset is there, it
 *     wraps a <picture>; if one is missing, the <img> stays JPEG and the miss is queued;
 *  2. a new upload pays for its own siblings, on `wp_generate_attachment_metadata`, while the
 *     person in Studio is already waiting for WordPress's thumbnails;
 *  3. leftover misses convert after the response (`fastcgi_finish_request` / LiteSpeed) or in a
 *     spawned cron. The next view finds the files and gets the <picture>.
 *
 * WHAT IT DOES NOT DO. It does not register the .webp as attachments — the Library stays the
 * customer's photographs. It does not rewrite Apache/nginx: hosting is not ours to configure. It
 * does not touch background-image in CSS (there is no <picture> for those). It does not convert
 * SVG, GIF, or a URL that is not a file in uploads.
 *
 * Naming: `hero.jpg` → `hero-jpg.webp`, `hero-1200x800.png` → `hero-1200x800-png.webp`. The original
 * extension lives in the basename so a file actually called `hero.webp` is not overwritten.
 */
class AIPB_WebP {

	/** Default encode quality, the same number WordPress uses for JPEG. */
	const QUALITY = 82;

	/**
	 * How many `save()` calls one background worker is allowed.
	 *
	 * The HTML request never hits this: it is the ceiling on the worker that runs AFTER the
	 * response, so a home with twenty photographs does not occupy an FPM process for seconds.
	 */
	const BUDGET = 8;

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

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

	/** Paths that still need encoding, keyed by path so a photograph is only queued once. */
	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() {
		add_filter( 'wp_generate_attachment_metadata', array( __CLASS__, 'on_upload' ), 10, 2 );
		add_action( 'shutdown', array( __CLASS__, 'on_shutdown' ), 100 );
		add_action( self::CRON, array( __CLASS__, 'cron' ) );
	}

	/**
	 * Whether this site can produce WebP at all.
	 *
	 * Filter `aipb_webp` turns the whole feature off. Missing editor support is the same as off:
	 * wrapping a <source type="image/webp"> that will never exist would be a lie.
	 */
	public static function enabled() {
		if ( ! apply_filters( 'aipb_webp', true ) ) {
			return false;
		}
		if ( ! function_exists( 'wp_image_editor_supports' ) ) {
			return false;
		}

		return (bool) wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) );
	}

	/* --------------------------------- Markup -------------------------------- */

	/**
	 * Wraps every eligible <img> in a <picture> whose <source> is WebP.
	 *
	 * Existing <picture> blocks are lifted out first: wrapping an <img> that is already a fallback
	 * would nest pictures, which is invalid and would make the browser ignore the original source.
	 *
	 * @param string $html
	 * @return string
	 */
	public static function wrap( $html ) {
		if ( ! self::enabled() || false === stripos( $html, '<img' ) ) {
			return $html;
		}

		$stash = array();
		$html  = preg_replace_callback(
			'#<picture\b[^>]*>.*?</picture>#is',
			function ( $m ) use ( &$stash ) {
				$key           = '<!--AIPB-PIC-' . count( $stash ) . '-->';
				$stash[ $key ] = $m[0];

				return $key;
			},
			$html
		);

		$html = preg_replace_callback(
			'/<img\b[^>]*>/i',
			array( __CLASS__, 'wrap_img' ),
			$html
		);

		if ( $stash ) {
			$html = str_replace( array_keys( $stash ), array_values( $stash ), $html );
		}

		return $html;
	}

	/**
	 * One <img> → <picture> when every local raster it names already has a sibling on disk.
	 *
	 * A miss queues the file and leaves the tag alone. Encoding here would be the TTFB we refused.
	 *
	 * @param string|array $tag the tag, or a preg_replace_callback match.
	 * @return string
	 */
	public static function wrap_img( $tag ) {
		$tag = is_array( $tag ) ? $tag[0] : (string) $tag;

		$src = self::attr( $tag, 'src' );
		if ( '' === $src || 0 === strpos( $src, 'data:' ) ) {
			return $tag;
		}

		$srcset = self::attr( $tag, 'srcset' );
		$sizes  = self::attr( $tag, 'sizes' );
		$items  = self::parse_srcset( $srcset );

		$webp_src = self::url( $src );
		if ( false === $webp_src ) {
			return $tag; // not a local jpeg/png: nothing to queue, nothing to wrap
		}

		$webp_items = array();
		$complete   = ( null !== $webp_src );
		foreach ( $items as $item ) {
			$w = self::url( $item['url'] );
			if ( false === $w ) {
				return $tag; // a srcset entry we cannot convert: wrapping would lie about the set
			}
			if ( null === $w ) {
				$complete = false;
				continue;
			}
			$webp_items[] = array(
				'url'  => $w,
				'desc' => $item['desc'],
			);
		}

		if ( ! $complete || null === $webp_src ) {
			return $tag;
		}

		if ( $webp_items ) {
			$webp_srcset = self::join_srcset( $webp_items );
		} else {
			$webp_srcset = $webp_src;
		}

		$source = '<source type="image/webp" srcset="' . esc_attr( $webp_srcset ) . '"';
		if ( '' !== $sizes ) {
			$source .= ' sizes="' . esc_attr( $sizes ) . '"';
		}
		$source .= '>';

		return '<picture>' . $source . $tag . '</picture>';
	}

	/* --------------------------------- Lookup -------------------------------- */

	/**
	 * The WebP URL for a raster, or null if it is ours but missing, or false if it is not ours.
	 *
	 * LOOKUP ONLY. A missing sibling is queued; it is not encoded. The HTML path has to stay a
	 * handful of file_exists calls.
	 *
	 * @param string $raster_url
	 * @return string|null|false
	 */
	public static function url( $raster_url ) {
		if ( ! self::enabled() ) {
			return false;
		}

		$src = self::locate( $raster_url );
		if ( ! $src ) {
			return false;
		}

		$dest = self::sibling_path( $src['path'] );
		if ( self::fresh( $src['path'], $dest ) ) {
			return self::path_to_url( $dest, $src );
		}

		self::enqueue( $src['path'] );

		return null;
	}

	/**
	 * `hero.jpg` → `hero-jpg.webp`. Public so the naming is testable without a disk.
	 *
	 * @param string $path
	 * @return string
	 */
	public static function sibling_path( $path ) {
		$ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
		if ( ! self::is_raster_ext( $ext ) ) {
			return '';
		}
		$dir  = dirname( $path );
		$name = pathinfo( $path, PATHINFO_FILENAME );

		return ( '.' === $dir || '' === $dir )
			? $name . '-' . $ext . '.webp'
			: $dir . '/' . $name . '-' . $ext . '.webp';
	}

	/** Paths waiting to be encoded, 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;
	}

	/* -------------------------------- Convert -------------------------------- */

	/**
	 * Encodes one file to its sibling. Returns true when the sibling exists afterwards.
	 *
	 * flock on a `.lock` next to the destination: two visitors must not encode the same photograph.
	 *
	 * @param string $path
	 * @return bool
	 */
	public static function convert( $path ) {
		if ( ! self::enabled() ) {
			return false;
		}

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

		$dest = self::sibling_path( $path );
		if ( '' === $dest ) {
			return false;
		}
		if ( self::fresh( $path, $dest ) ) {
			return true;
		}

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

			return false;
		}

		$ok = false;
		if ( self::fresh( $path, $dest ) ) {
			$ok = true;
		} elseif ( function_exists( 'wp_get_image_editor' ) ) {
			$editor = wp_get_image_editor( $path );
			if ( ! is_wp_error( $editor ) ) {
				$quality = (int) apply_filters( 'aipb_webp_quality', self::QUALITY );
				if ( $quality < 1 ) {
					$quality = 1;
				}
				if ( $quality > 100 ) {
					$quality = 100;
				}
				$editor->set_quality( $quality );
				$saved = $editor->save( $dest, 'image/webp' );
				$ok    = ! is_wp_error( $saved ) && self::fresh( $path, $dest );
			}
		}

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

		return $ok;
	}

	/**
	 * Original + every generated size of an attachment, encoded while the uploader waits.
	 *
	 * WordPress is already building thumbnails on this hook. Adding the siblings here is the same
	 * wait, paid by the person who chose the photograph, not by the visitor.
	 *
	 * @param array $metadata
	 * @param int   $attachment_id
	 * @return array
	 */
	public static function on_upload( $metadata, $attachment_id ) {
		if ( ! self::enabled() || ! is_array( $metadata ) ) {
			return $metadata;
		}
		if ( $attachment_id && function_exists( 'wp_attachment_is_image' ) && ! wp_attachment_is_image( $attachment_id ) ) {
			return $metadata;
		}

		foreach ( self::attachment_paths( $attachment_id, $metadata ) as $path ) {
			self::convert( $path );
		}

		return $metadata;
	}

	/** After the response: encode 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();
	}

	/** Cron worker: the option is the queue, so two events within ten minutes still drain it. */
	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();
		}
	}

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

	protected static function run_queue() {
		$limit = (int) apply_filters( 'aipb_webp_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 ] );
			if ( self::fresh( $path, self::sibling_path( $path ) ) ) {
				continue;
			}
			if ( self::convert( $path ) ) {
				++$n;
			}
		}
	}

	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 ) {
		self::$queue[ $path ] = true;
	}

	protected static function fresh( $source, $dest ) {
		return ( '' !== $dest && is_file( $source ) && is_file( $dest ) && filemtime( $dest ) >= filemtime( $source ) );
	}

	protected static function is_raster_ext( $ext ) {
		return in_array( $ext, array( 'jpg', 'jpeg', 'png' ), true );
	}

	/**
	 * A URL the page printed → the file in uploads, or null when it is not ours.
	 *
	 * Three routes, cheapest first: the uploads baseurl (scheme-flexible), the path under
	 * /wp-content/uploads, then the attachment id. Offloaded media with no local file falls
	 * through: is_file fails, and that is a no-op, not an error.
	 *
	 * @param string $url
	 * @return array{path:string,url:string,basedir:string,baseurl: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 ( ! self::is_raster_ext( $ext ) ) {
			return null;
		}

		$uploads = self::uploads();
		if ( ! $uploads ) {
			return null;
		}

		$path = self::path_from_url( $clean, $uploads );
		if ( '' === $path && class_exists( 'AIPB_Template_Engine' ) && function_exists( 'get_attached_file' ) ) {
			$id = AIPB_Template_Engine::attachment_id( $clean );
			if ( $id ) {
				$file = get_attached_file( $id );
				if ( $file ) {
					$path = dirname( $file ) . '/' . basename( $clean );
				}
			}
		}

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

		$real = realpath( $path );
		$root = realpath( $uploads['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,
			'basedir' => $root,
			'baseurl' => $uploads['baseurl'],
		);
	}

	protected static function path_from_url( $clean, $uploads ) {
		$baseurl = $uploads['baseurl'];
		$basedir = $uploads['basedir'];

		$clean_ns = preg_replace( '#^https?:#i', '', $clean );
		$base_ns  = preg_replace( '#^https?:#i', '', $baseurl );
		if ( 0 === strpos( $clean, $baseurl ) ) {
			return $basedir . substr( $clean, strlen( $baseurl ) );
		}
		if ( 0 === strpos( $clean_ns, $base_ns ) ) {
			return $basedir . substr( $clean_ns, strlen( $base_ns ) );
		}

		$path_part = (string) wp_parse_url( $clean, PHP_URL_PATH );
		$base_part = (string) wp_parse_url( $baseurl, PHP_URL_PATH );
		if ( '' !== $path_part && '' !== $base_part && 0 === strpos( $path_part, $base_part ) ) {
			return $basedir . substr( $path_part, strlen( $base_part ) );
		}

		return '';
	}

	protected static function path_to_url( $dest, $src ) {
		$dest = self::normalize( $dest );
		$root = $src['basedir'];
		if ( 0 === strpos( $dest, $root ) ) {
			return $src['baseurl'] . str_replace( '\\', '/', substr( $dest, strlen( $root ) ) );
		}

		return preg_replace( '/\.(jpe?g|png)$/i', '-' . strtolower( (string) pathinfo( $src['url'], PATHINFO_EXTENSION ) ) . '.webp', $src['url'] );
	}

	protected static function uploads() {
		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;
		}

		return array(
			'basedir' => self::normalize( untrailingslashit( $dir['basedir'] ) ),
			'baseurl' => untrailingslashit( $dir['baseurl'] ),
		);
	}

	protected static function attachment_paths( $attachment_id, $metadata ) {
		$out = array();
		if ( $attachment_id && function_exists( 'get_attached_file' ) ) {
			$file = get_attached_file( (int) $attachment_id );
			if ( $file && is_file( $file ) ) {
				$out[] = self::normalize( $file );
			}
		}

		$uploads = self::uploads();
		if ( $uploads && ! empty( $metadata['file'] ) ) {
			$full = $uploads['basedir'] . '/' . ltrim( str_replace( '\\', '/', (string) $metadata['file'] ), '/' );
			$dir  = dirname( $full );
			if ( is_file( $full ) ) {
				$out[] = self::normalize( $full );
			}
			if ( ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
				foreach ( $metadata['sizes'] as $size ) {
					if ( empty( $size['file'] ) ) {
						continue;
					}
					$p = $dir . '/' . basename( (string) $size['file'] );
					if ( is_file( $p ) ) {
						$out[] = self::normalize( $p );
					}
				}
			}
			if ( ! empty( $metadata['original_image'] ) ) {
				$orig = $dir . '/' . basename( (string) $metadata['original_image'] );
				if ( is_file( $orig ) ) {
					$out[] = self::normalize( $orig );
				}
			}
		}

		return array_values( array_unique( $out ) );
	}

	protected static function parse_srcset( $srcset ) {
		$out = array();
		if ( '' === trim( (string) $srcset ) ) {
			return $out;
		}
		foreach ( explode( ',', $srcset ) as $part ) {
			$part = trim( $part );
			if ( '' === $part ) {
				continue;
			}
			if ( preg_match( '/^(\S+)(?:\s+(\d+w|\d+(?:\.\d+)?x))?$/', $part, $m ) ) {
				$out[] = array(
					'url'  => html_entity_decode( $m[1], ENT_QUOTES ),
					'desc' => isset( $m[2] ) ? $m[2] : '',
				);
			}
		}

		return $out;
	}

	protected static function join_srcset( $items ) {
		$parts = array();
		foreach ( $items as $item ) {
			$parts[] = $item['url'] . ( '' !== $item['desc'] ? ' ' . $item['desc'] : '' );
		}

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

	protected static function attr( $tag, $name ) {
		if ( ! preg_match( '/\b' . preg_quote( $name, '/' ) . '\s*=\s*("([^"]*)"|\'([^\']*)\')/i', $tag, $m ) ) {
			return '';
		}

		$val = ( isset( $m[3] ) && '' !== $m[3] ) ? $m[3] : $m[2];

		return html_entity_decode( trim( $val ), ENT_QUOTES );
	}

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

		return $path;
	}
}
