<?php
/**
 * BaseCloud Boost — Image Optimizer
 *
 * @package BaseCloud\Boost\Optimizer
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Optimizer;

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

/**
 * Rewrites image URLs to the best available modern format (AVIF → WebP → original).
 *
 * The actual modern-format files are produced by {@see Image_Compressor} (on upload
 * or via the Bulk Compress tool). This class only *serves* them when they exist.
 *
 * To cover output from every page builder (Elementor, Divi, Beaver Builder, Bricks,
 * etc.) — not just `the_content` — modernization runs as part of a single full-page
 * pass. When the Lazy Loader is active it performs that pass and calls into us, so we
 * never run a second, redundant full-page regex pass.
 */
class Image_Optimizer {

	/**
	 * Per-request memo of URL → modern URL|false, so a page that repeats the same
	 * image (galleries, logos, thumbnails) only ever stats the disk once.
	 *
	 * @var array<string, string|false>
	 */
	private static array $lookup_cache = [];

	/**
	 * Per-request memo of file path → [width, height]|null, so verifying a
	 * source against both its .avif and .webp siblings reads its header once.
	 *
	 * @var array<string, array{0:int,1:int}|null>
	 */
	private static array $dims_cache = [];

	/**
	 * Per-request memo of file path → truncation verdict, so the cheap
	 * structural check runs at most once per unique file per request.
	 *
	 * @var array<string, bool>
	 */
	private static array $truncation_cache = [];

	/**
	 * Files already logged as truncated this request (log once, no spam).
	 *
	 * @var array<string, bool>
	 */
	private static array $truncation_logged = [];

	/**
	 * Boot image optimizer.
	 *
	 * Modern-format serving runs inside the Lazy Loader's single full-page pass
	 * (which calls {@see modernize_attrs()}), so this class registers no output
	 * buffer of its own — that avoids a second, redundant full-page regex pass.
	 *
	 * @return void
	 */
	public function boot(): void {
		// Intentionally empty: {@see Lazy_Loader} drives the pass and calls into us.
	}

	/**
	 * Whether modern-format serving is enabled at all.
	 *
	 * @return bool
	 */
	public static function is_enabled(): bool {
		// When the BunnyCDN Optimizer is active it negotiates WebP/AVIF (with JPEG
		// fallback) at the edge for every CDN-served image. Doing our own format
		// rewriting on top would be redundant and would override the edge's
		// per-browser choice (and could force WebP where the browser prefers AVIF or
		// needs a fallback), so we stand down and let the edge handle it.
		if ( BCBOOST_bunny_optimizer_active() ) {
			return false;
		}
		return (bool) BCBOOST_get_option( 'serve_avif', false )
			|| (bool) BCBOOST_get_option( 'serve_webp', true );
	}

	/**
	 * Rewrite every <img> in an HTML fragment/document to a modern format.
	 * Kept public for direct calls / extensions.
	 *
	 * @param string $html HTML content.
	 * @return string Modified HTML.
	 */
	public function rewrite_to_modern_format( string $html ): string {
		if ( empty( $html ) ) {
			return $html;
		}
		return preg_replace_callback(
			'/<img([^>]+)>/i',
			function ( array $m ): string {
				return '<img' . $this->modernize_attrs( $m[1] ) . '>';
			},
			$html
		) ?? $html;
	}

	/**
	 * Rewrite the src (and every srcset candidate) inside an <img> attribute string
	 * to the best available modern format. Safe to call more than once — already
	 * modern URLs are left untouched.
	 *
	 * @param string $attrs Raw attribute string from inside an <img …> tag.
	 * @return string Possibly-modified attribute string.
	 */
	public function modernize_attrs( string $attrs ): string {
		// ── src ──────────────────────────────────────────────────────────
		// Negative lookbehind avoids matching data-src / data-lazy-src etc.
		$attrs = preg_replace_callback(
			'/(?<![-\w])src\s*=\s*(["\'])(.*?)\1/i',
			function ( array $m ): string {
				$modern = $this->modernize_url( $m[2] );
				return $modern ? 'src=' . $m[1] . esc_attr( $modern ) . $m[1] : $m[0];
			},
			$attrs,
			1
		) ?? $attrs;

		// ── srcset ─────────────────────────────────────────────────────────
		$attrs = preg_replace_callback(
			'/(?<![-\w])srcset\s*=\s*(["\'])(.*?)\1/i',
			function ( array $m ): string {
				$rewritten = $this->modernize_srcset( $m[2] );
				return 'srcset=' . $m[1] . $rewritten . $m[1];
			},
			$attrs,
			1
		) ?? $attrs;

		return $attrs;
	}

	/**
	 * Rewrite each candidate in a srcset string to its modern equivalent.
	 *
	 * @param string $srcset Raw srcset value.
	 * @return string Rewritten srcset value.
	 */
	private function modernize_srcset( string $srcset ): string {
		$candidates = explode( ',', $srcset );
		foreach ( $candidates as &$candidate ) {
			$parts = preg_split( '/\s+/', trim( $candidate ), 2 );
			if ( empty( $parts[0] ) ) {
				continue;
			}
			$modern = $this->modernize_url( $parts[0] );
			if ( $modern ) {
				$candidate = $modern . ( isset( $parts[1] ) ? ' ' . $parts[1] : '' );
			}
		}
		unset( $candidate );
		return implode( ', ', array_map( 'trim', $candidates ) );
	}

	/**
	 * Resolve a single image URL to the best modern format on disk, with a
	 * per-request memo so repeated images never re-stat the filesystem.
	 *
	 * @param string $url Original image URL.
	 * @return string|null Modern-format URL, or null if none exists.
	 */
	public function modernize_url( string $url ): ?string {
		if ( $url === '' || strpos( $url, 'data:' ) === 0 ) {
			return null;
		}

		if ( array_key_exists( $url, self::$lookup_cache ) ) {
			$hit = self::$lookup_cache[ $url ];
			return $hit === false ? null : $hit;
		}

		$result = $this->resolve_modern_url( $url );
		self::$lookup_cache[ $url ] = $result ?? false;
		return $result;
	}

	/**
	 * Uncached disk resolution of a modern-format URL (AVIF first, then WebP).
	 *
	 * @param string $url Original image URL.
	 * @return string|null
	 */
	private function resolve_modern_url( string $url ): ?string {
		// Already a modern format — nothing to do.
		if ( preg_match( '/\.(?:avif|webp)(?:\?|#|$)/i', $url ) ) {
			return null;
		}

		if ( BCBOOST_get_option( 'serve_avif', false ) ) {
			$avif = $this->get_modern_url( $url, 'avif' );
			if ( $avif ) {
				return $avif;
			}
		}
		if ( BCBOOST_get_option( 'serve_webp', true ) ) {
			$webp = $this->get_modern_url( $url, 'webp' );
			if ( $webp ) {
				return $webp;
			}
		}
		return null;
	}

	/**
	 * Get a modern-format URL for an image if the file exists in the uploads dir.
	 *
	 * @param string $url    Original image URL.
	 * @param string $format Target format: 'avif' or 'webp'.
	 * @return string|null   Modern-format URL, or null if not found.
	 */
	private function get_modern_url( string $url, string $format ): ?string {
		$upload_dir = wp_get_upload_dir();
		$base_url   = $upload_dir['baseurl'];
		$base_dir   = $upload_dir['basedir'];

		// Compare against a protocol-agnostic base so http/https/relative all match.
		$clean_url = preg_replace( '#^https?:#i', '', $url );
		$clean_b   = preg_replace( '#^https?:#i', '', $base_url );

		if ( strpos( $clean_url, (string) $clean_b ) !== 0 ) {
			return null;
		}

		$relative   = substr( $clean_url, strlen( (string) $clean_b ) );
		$relative   = strtok( $relative, '?#' ); // drop query/hash
		$modern_rel = preg_replace( '/\.(jpe?g|png|gif)$/i', '.' . $format, (string) $relative );

		if ( $modern_rel === $relative || $modern_rel === null ) {
			return null; // No replaceable extension.
		}

		if ( file_exists( $base_dir . $modern_rel )
			&& self::is_verified_conversion( $base_dir . $relative, $base_dir . $modern_rel ) ) {
			return $base_url . $modern_rel;
		}

		return null;
	}

	/**
	 * A modern-format sibling is only served when it is verifiably a CONVERSION
	 * of the original — i.e. both files exist and have identical pixel
	 * dimensions.
	 *
	 * Root-cause guard (quatro.co.za regression, 1.3.1): a client can upload a
	 * *separate* media file that happens to share a basename with a JPEG/PNG —
	 * e.g. an unrelated 1442×400 `Hygiene_Services.webp` living beside a 701×450
	 * `Hygiene_Services.jpg`. A bare file_exists() match then swapped a
	 * completely different picture into the page. Reading just the image headers
	 * of both files is cheap (getimagesize does not decode pixels) and is
	 * memoized per request, and any unreadable/ambiguous pair fails open to the
	 * ORIGINAL image — we may miss a byte saving, but we can never show the
	 * wrong picture.
	 *
	 * @param string $source_path Absolute path of the original JPEG/PNG/GIF.
	 * @param string $modern_path Absolute path of the .avif/.webp sibling.
	 * @return bool True only when both headers are readable and dimensions match.
	 */
	public static function is_verified_conversion( string $source_path, string $modern_path ): bool {
		/**
		 * Filter whether modern-format siblings must match the original's pixel
		 * dimensions before being served. Return false only if you are certain
		 * no same-basename foreign uploads exist (skips two header reads per
		 * unique image on uncached renders).
		 *
		 * @param bool $verify Default true.
		 */
		if ( ! apply_filters( 'BCBOOST_verify_modern_conversion', true ) ) {
			return true;
		}

		$src = self::image_dims( $source_path );
		$mod = self::image_dims( $modern_path );
		if ( null === $src || null === $mod ) {
			// Header unreadable (missing original, corrupt file, or an AVIF on
			// PHP < 8.1 where getimagesize() can't parse it): we cannot prove
			// the sibling is ours — serve the original instead.
			return false;
		}
		if ( $src[0] !== $mod[0] || $src[1] !== $mod[1] ) {
			return false;
		}

		// bfap.co.za guard: a worker killed mid-write (pre-atomic-rename builds)
		// leaves a TRUNCATED file whose header (AVIF ispe / WebP VP8X-VP8L bits)
		// still yields valid dimensions — so the dimension check alone passed and
		// the broken file was served as a blank image. Structural sanity is cheap
		// (a 16-byte header read + container size bookkeeping); a full decode is
		// not, so deep validation lives in Image_Compressor + the
		// `wp bcboost verify-conversions` sweep. False positives here only ever
		// fall back to the original image — never a broken one.
		return ! self::looks_truncated( $modern_path );
	}

	/**
	 * Whether two files have identical pixel dimensions (header-only reads).
	 *
	 * This is the OWNERSHIP half of {@see is_verified_conversion()}: identical
	 * dimensions mean the sibling is our conversion (not a foreign same-basename
	 * upload — the quatro.co.za guard) even when its payload is truncated. The
	 * compressor uses this to decide it may REGENERATE a broken sibling that the
	 * integrity-aware check above refuses to serve.
	 *
	 * @param string $source_path Absolute path of the original JPEG/PNG/GIF.
	 * @param string $modern_path Absolute path of the .avif/.webp sibling.
	 * @return bool True only when both headers are readable and dimensions match.
	 */
	public static function dims_match( string $source_path, string $modern_path ): bool {
		$src = self::image_dims( $source_path );
		$mod = self::image_dims( $modern_path );
		return null !== $src && null !== $mod && $src[0] === $mod[0] && $src[1] === $mod[1];
	}

	/**
	 * Cheap structural truncation/corruption heuristic for a .webp/.avif file.
	 *
	 * Conservative by design: it only flags files that are PROVABLY inconsistent —
	 * (a) byte size below a plausible floor for the pixel area
	 *     (`max(200, area/512)` bytes),
	 * (b) a RIFF/WebP container whose declared size exceeds the actual file, or
	 * (c) an ISO-BMFF/AVIF file whose top-level boxes overrun the actual file.
	 * It never decodes pixels, is memoized per request, and logs each offender
	 * once under WP_DEBUG_LOG. A false positive merely serves the original image.
	 *
	 * @param string $path Absolute path of the modern-format file.
	 * @return bool True when the file looks truncated/corrupt.
	 */
	public static function looks_truncated( string $path ): bool {
		if ( array_key_exists( $path, self::$truncation_cache ) ) {
			return self::$truncation_cache[ $path ];
		}

		$verdict = self::compute_truncation( $path );

		self::$truncation_cache[ $path ] = $verdict;
		if ( $verdict && empty( self::$truncation_logged[ $path ] ) ) {
			self::$truncation_logged[ $path ] = true;
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[BaseCloud Boost] Image Optimizer: ' . basename( $path ) . ' looks truncated/corrupt — serving the original instead. Run `wp bcboost verify-conversions --delete-broken --regenerate` to repair.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			}
		}
		return $verdict;
	}

	/**
	 * Drop all per-request memos for a file after it was (re)generated or deleted.
	 *
	 * @param string $path Absolute file path.
	 * @return void
	 */
	public static function forget_file( string $path ): void {
		unset( self::$dims_cache[ $path ], self::$truncation_cache[ $path ], self::$truncation_logged[ $path ] );
		clearstatcache( true, $path );
	}

	/**
	 * Uncached structural truncation verdict. {@see looks_truncated()}.
	 *
	 * @param string $path Absolute file path.
	 * @return bool
	 */
	private static function compute_truncation( string $path ): bool {
		$size = @filesize( $path );
		if ( ! is_int( $size ) || $size < 26 ) {
			return true; // Smaller than any valid WebP/AVIF can be.
		}

		$fh = @fopen( $path, 'rb' );
		if ( ! $fh ) {
			return true; // Unreadable — cannot be served either way.
		}

		$head    = (string) fread( $fh, 16 );
		$verdict = false;

		if ( strlen( $head ) >= 16 && 'RIFF' === substr( $head, 0, 4 ) && 'WEBP' === substr( $head, 8, 4 ) ) {
			// The RIFF header declares the container size at bytes 4–7; a file
			// shorter than its own declaration was cut off mid-write.
			$declared = (int) ( unpack( 'V', substr( $head, 4, 4 ) )[1] ?? 0 ) + 8;
			$verdict  = ( $declared > $size ) || self::below_plausible_floor( $path, $size );
		} elseif ( strlen( $head ) >= 12 && 'ftyp' === substr( $head, 4, 4 ) ) {
			$verdict = self::isobmff_truncated( $fh, $size );
			// getimagesize() can only parse AVIF headers on PHP 8.1+; without it
			// there is no pixel area for the floor — the box walk must suffice.
			if ( ! $verdict && defined( 'IMAGETYPE_AVIF' ) ) {
				$verdict = self::below_plausible_floor( $path, $size );
			}
		} else {
			$verdict = true; // Neither WebP nor ISO-BMFF magic — not a usable modern file.
		}

		fclose( $fh );
		return $verdict;
	}

	/**
	 * Whether a file is smaller than plausibly possible for its pixel area.
	 *
	 * @param string $path Absolute file path.
	 * @param int    $size Actual byte size.
	 * @return bool
	 */
	private static function below_plausible_floor( string $path, int $size ): bool {
		$dims = self::image_dims( $path );
		if ( null === $dims ) {
			return true; // Magic bytes said image, but the header does not parse.
		}
		$floor = (int) max( 200, ( $dims[0] * $dims[1] ) / 512 );
		return $size < $floor;
	}

	/**
	 * Walk the top-level ISO-BMFF boxes of an AVIF file: any box whose declared
	 * size overruns the actual file size proves a truncated write.
	 *
	 * @param resource $fh   Open read handle (position is not preserved).
	 * @param int      $size Actual byte size of the file.
	 * @return bool True when the box structure is inconsistent with the size.
	 */
	private static function isobmff_truncated( $fh, int $size ): bool {
		$offset = 0;
		$boxes  = 0;

		while ( $offset + 8 <= $size && $boxes < 64 ) {
			if ( 0 !== fseek( $fh, $offset ) ) {
				return true;
			}
			$hdr = (string) fread( $fh, 8 );
			if ( strlen( $hdr ) < 8 ) {
				return true;
			}

			$len        = (int) ( unpack( 'N', substr( $hdr, 0, 4 ) )[1] ?? 0 );
			$header_len = 8;

			if ( 0 === $len ) {
				return false; // Box extends to EOF — consistent by definition.
			}
			if ( 1 === $len ) { // 64-bit "largesize" follows the type.
				$ext = (string) fread( $fh, 8 );
				if ( strlen( $ext ) < 8 ) {
					return true;
				}
				$len        = (int) ( unpack( 'J', $ext )[1] ?? 0 );
				$header_len = 16;
			}

			if ( $len < $header_len ) {
				return true; // Nonsense size.
			}
			if ( $offset + $len > $size ) {
				return true; // Declared box overruns the file — truncated.
			}

			$offset += $len;
			$boxes++;
		}

		// A valid file ends exactly on a top-level box boundary.
		return $offset !== $size;
	}

	/**
	 * Header-only image dimensions with a per-request memo.
	 *
	 * @param string $path Absolute file path.
	 * @return array{0:int,1:int}|null [width, height], or null when unreadable.
	 */
	private static function image_dims( string $path ): ?array {
		if ( array_key_exists( $path, self::$dims_cache ) ) {
			return self::$dims_cache[ $path ];
		}
		$info = ( is_file( $path ) && is_readable( $path ) ) ? @getimagesize( $path ) : false;
		$dims = ( is_array( $info ) && ! empty( $info[0] ) && ! empty( $info[1] ) )
			? [ (int) $info[0], (int) $info[1] ]
			: null;
		self::$dims_cache[ $path ] = $dims;
		return $dims;
	}
}
