<?php
/**
 * Cache module — Image Dimensions
 *
 * Auto-fills width/height attributes on <img> tags that don't have them.
 * Reduces Cumulative Layout Shift (CLS) by letting the browser reserve the
 * correct space before the image bytes arrive.
 *
 * Lookup strategy (cheapest first):
 *   1. Local attachment via attachment_url_to_postid() + wp_get_attachment_metadata()
 *   2. Fallback @getimagesize() on the resolved local file path
 *   3. Skip if the image is remote and not resolvable cheaply (we don't HTTP-probe)
 *
 * Resolved dimensions are cached in a single autoload-disabled option keyed by
 * URL hash to avoid hammering the DB/filesystem on every cache miss.
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Image_Dimensions {

	const CACHE_OPTION = 'blaminhor_essentials_cache_img_dims';
	const CACHE_LIMIT  = 500;

	/** @var Blaminhor_Essentials_Module_Cache */
	private $parent;

	/** @var array<string,array{int,int}|false> */
	private $dim_cache = null;

	/** @var bool Whether the cache was modified during this request. */
	private $dim_cache_dirty = false;

	public function __construct( Blaminhor_Essentials_Module_Cache $parent ) {
		$this->parent = $parent;
	}

	public function register_hooks() {
		// Run before lazyload (priority 20) so the lazyload pass sees the
		// dimensions we wrote and can keep them around its own attribute swap.
		add_filter( 'blaminhor_essentials_cache_html_buffer', array( $this, 'optimize_buffer' ), 15, 1 );
		add_action( 'shutdown', array( $this, 'maybe_persist_cache' ), 99 );
	}

	/**
	 * Walk every <img> in the buffered HTML and inject width/height when missing.
	 *
	 * @param string $html
	 * @return string
	 */
	public function optimize_buffer( $html ) {
		if ( ! is_string( $html ) || '' === $html ) {
			return $html;
		}
		// Defence-in-depth bypass: html_buffer is normally only fired inside
		// the page-cache OB which is itself gated, but third-party callers may
		// invoke the filter from another path.
		if ( $this->parent->should_skip_runtime_optimizations() ) {
			return $html;
		}
		if ( ! $this->parent->get_setting( 'image_dimensions_enabled', true ) ) {
			return $html;
		}
		// Skip if no <img> at all — fast path.
		if ( false === stripos( $html, '<img' ) ) {
			return $html;
		}

		return preg_replace_callback(
			'#<img\b([^>]*?)/?>#i',
			array( $this, 'process_img_tag' ),
			$html
		);
	}

	/**
	 * Decide what (if anything) to inject into a single <img> tag.
	 *
	 * @param array $m
	 * @return string
	 */
	private function process_img_tag( $m ) {
		$attrs = $m[1];

		// Skip emojis and smileys — WP already pins their size via CSS.
		if ( false !== stripos( $attrs, 'wp-smiley' ) || false !== stripos( $attrs, 'class="emoji"' ) ) {
			return $m[0];
		}

		$has_width  = (bool) preg_match( '/\swidth\s*=/i', $attrs );
		$has_height = (bool) preg_match( '/\sheight\s*=/i', $attrs );
		if ( $has_width && $has_height ) {
			return $m[0];
		}

		if ( ! preg_match( '/\ssrc\s*=\s*("([^"]+)"|\'([^\']+)\'|([^\s>]+))/i', $attrs, $sm ) ) {
			return $m[0];
		}
		$src = '' !== ( $sm[2] ?? '' ) ? $sm[2] : ( '' !== ( $sm[3] ?? '' ) ? $sm[3] : ( $sm[4] ?? '' ) );
		$src = trim( $src );
		if ( '' === $src || 0 === strpos( $src, 'data:' ) ) {
			return $m[0];
		}

		$dims = $this->resolve_dimensions( $src );
		if ( false === $dims ) {
			return $m[0];
		}

		$injected = '';
		if ( ! $has_width ) {
			$injected .= ' width="' . (int) $dims[0] . '"';
		}
		if ( ! $has_height ) {
			$injected .= ' height="' . (int) $dims[1] . '"';
		}

		return '<img' . $injected . $attrs . '>';
	}

	/**
	 * Look up the intrinsic width/height for a given image URL.
	 * Returns false when we cannot determine them cheaply.
	 *
	 * @param string $src
	 * @return array{int,int}|false
	 */
	private function resolve_dimensions( $src ) {
		$key = md5( $src );
		$cache = $this->load_dim_cache();
		if ( array_key_exists( $key, $cache ) ) {
			return $cache[ $key ];
		}

		$dims = $this->compute_dimensions( $src );
		// Always memoise — even false — so we don't keep retrying lost causes.
		$this->dim_cache[ $key ] = $dims;
		$this->dim_cache_dirty   = true;

		// Bound the cache to keep the option small.
		if ( count( $this->dim_cache ) > self::CACHE_LIMIT ) {
			$this->dim_cache = array_slice( $this->dim_cache, -self::CACHE_LIMIT, null, true );
		}

		return $dims;
	}

	/**
	 * @param string $src
	 * @return array{int,int}|false
	 */
	private function compute_dimensions( $src ) {
		// Strip query strings (eg ?ver=) so URLs match the canonical attachment URL.
		$clean = preg_replace( '/[?#].*$/', '', $src );
		if ( ! is_string( $clean ) || '' === $clean ) {
			return false;
		}

		// Try WP attachment lookup first (cheap, in-DB).
		if ( function_exists( 'attachment_url_to_postid' ) ) {
			$attachment_id = attachment_url_to_postid( $clean );
			if ( $attachment_id ) {
				$meta = wp_get_attachment_metadata( $attachment_id );
				if ( is_array( $meta ) && ! empty( $meta['width'] ) && ! empty( $meta['height'] ) ) {
					// If the URL points to a sized variant, prefer that variant's size.
					$file_basename = wp_basename( $clean );
					if ( ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) {
						foreach ( $meta['sizes'] as $size_meta ) {
							if ( isset( $size_meta['file'] ) && $size_meta['file'] === $file_basename
								&& ! empty( $size_meta['width'] ) && ! empty( $size_meta['height'] ) ) {
								return array( (int) $size_meta['width'], (int) $size_meta['height'] );
							}
						}
					}
					return array( (int) $meta['width'], (int) $meta['height'] );
				}
			}
		}

		// Fallback: resolve to a local file path and getimagesize() it.
		$path = $this->url_to_local_path( $clean );
		if ( '' !== $path && is_file( $path ) ) {
			// SVG: getimagesize() returns nothing useful, parse the file instead.
			if ( preg_match( '/\.svg$/i', $path ) ) {
				$svg_dims = $this->extract_svg_dimensions( $path );
				if ( false !== $svg_dims ) {
					return $svg_dims;
				}
			}
			$info = @getimagesize( $path );
			if ( is_array( $info ) && ! empty( $info[0] ) && ! empty( $info[1] ) ) {
				return array( (int) $info[0], (int) $info[1] );
			}
		}

		return false;
	}

	/**
	 * Extract pixel dimensions from a local SVG file. Reads the first 4 KB
	 * (the root <svg> tag is always near the start) and looks for explicit
	 * width/height attributes first, then falls back to the viewBox.
	 *
	 * @param string $path
	 * @return array{int,int}|false
	 */
	private function extract_svg_dimensions( $path ) {
		$head = @file_get_contents( $path, false, null, 0, 4096 );
		if ( false === $head || '' === $head ) {
			return false;
		}
		// Try explicit numeric width/height attributes on the <svg> tag.
		if ( preg_match( '/<svg\b[^>]*\swidth\s*=\s*["\']?\s*(\d+(?:\.\d+)?)/i', $head, $w )
			&& preg_match( '/<svg\b[^>]*\sheight\s*=\s*["\']?\s*(\d+(?:\.\d+)?)/i', $head, $h ) ) {
			$wv = (int) round( (float) $w[1] );
			$hv = (int) round( (float) $h[1] );
			if ( $wv > 0 && $hv > 0 ) {
				return array( $wv, $hv );
			}
		}
		// Fallback: viewBox="x y width height".
		if ( preg_match( '/<svg\b[^>]*\sviewBox\s*=\s*["\']\s*([\-\d.]+)\s+([\-\d.]+)\s+([\d.]+)\s+([\d.]+)/i', $head, $vb ) ) {
			$wv = (int) round( (float) $vb[3] );
			$hv = (int) round( (float) $vb[4] );
			if ( $wv > 0 && $hv > 0 ) {
				return array( $wv, $hv );
			}
		}
		return false;
	}

	/**
	 * Map a URL to a local filesystem path, when possible. Returns '' otherwise.
	 *
	 * @param string $url
	 * @return string
	 */
	private function url_to_local_path( $url ) {
		$site = site_url();
		$home = home_url();
		foreach ( array( $site, $home ) as $base ) {
			if ( '' === $base ) {
				continue;
			}
			if ( 0 === strpos( $url, $base ) ) {
				$rel = substr( $url, strlen( $base ) );
				$candidate = ABSPATH . ltrim( $rel, '/' );
				$real = realpath( $candidate );
				if ( false === $real ) {
					continue;
				}
				// Stay inside ABSPATH to avoid traversal surprises.
				$abs_real = realpath( ABSPATH );
				if ( false !== $abs_real && 0 === strpos( $real, $abs_real ) ) {
					return $real;
				}
			}
		}
		return '';
	}

	/**
	 * @return array<string,array{int,int}|false>
	 */
	private function load_dim_cache() {
		if ( null === $this->dim_cache ) {
			$cached = get_option( self::CACHE_OPTION, array() );
			$this->dim_cache = is_array( $cached ) ? $cached : array();
		}
		return $this->dim_cache;
	}

	public function maybe_persist_cache() {
		if ( ! $this->dim_cache_dirty || null === $this->dim_cache ) {
			return;
		}
		// Autoload disabled — only the page-cache shutdown reads it.
		update_option( self::CACHE_OPTION, $this->dim_cache, false );
		$this->dim_cache_dirty = false;
	}
}
