<?php
/**
 * CacheLoop – Lazy Loading (Free) + LCP Image Priority (Pro)
 *
 * Enforces native lazy loading on all <img> and <iframe> elements by
 * filtering the WordPress output buffer. Falls back gracefully if the
 * attribute is already present.
 *
 * Pro: Skips lazy loading on the first N images (above the fold) and adds
 * fetchpriority="high" to the very first image, boosting LCP dramatically.
 *
 * @package CacheLoop
 */

// Security: Prevent direct file access.
defined( 'ABSPATH' ) || exit;

/**
 * Class CACHELOOP_Lazy_Load
 *
 * Adds loading="lazy" to images and iframes via output buffering.
 */
class CACHELOOP_Lazy_Load {

	/**
	 * Number of images to exclude from lazy loading (above the fold).
	 * Set by Pro option 'lcp_image_count'. Default 1.
	 *
	 * @var int
	 */
	private $lcp_image_count = 1;

	/**
	 * Counter to track how many images have been processed.
	 *
	 * @var int
	 */
	private $img_counter = 0;

	/**
	 * Constructor: hooks into WordPress.
	 *
	 * @param array $settings Plugin settings array.
	 */
	public function __construct( $settings = array() ) {
		// Read Pro LCP setting (how many leading images to keep eager).
		if ( ! empty( $settings['lcp_image_count'] ) ) {
			$this->lcp_image_count = max( 1, (int) $settings['lcp_image_count'] );
		}

		// Ensure WordPress native lazy loading is active (WP 5.5+).
		add_filter( 'wp_lazy_loading_enabled', '__return_true' );

		// For elements not handled by WordPress core (e.g., hand-coded iframes),
		// we hook into the full HTML output buffer.
		add_action( 'template_redirect', array( $this, 'start_output_buffer' ) );
	}

	/**
	 * Starts output buffering so we can process the full HTML.
	 *
	 * @return void
	 */
	public function start_output_buffer() {
		// Don't run for admin, feeds, or when another plugin handles output.
		if ( is_admin() || is_feed() ) {
			return;
		}

		ob_start( array( $this, 'add_lazy_attributes' ) );
	}

	/**
	 * Output buffer callback: injects loading="lazy" on <img> and <iframe> tags.
	 * First N images get loading="eager" + fetchpriority="high" instead (LCP boost).
	 *
	 * @param  string $html The full HTML document.
	 * @return string       The modified HTML document.
	 */
	public function add_lazy_attributes( $html ) {
		if ( empty( $html ) ) {
			return $html;
		}

		// Reset counter for each page.
		$this->img_counter = 0;

		// Add loading="lazy" to <img> tags missing it (with LCP logic).
		$html = preg_replace_callback(
			'/<img([^>]+)>/i',
			array( $this, 'inject_lazy_img' ),
			$html
		);

		// Add loading="lazy" to <iframe> tags missing it.
		$html = preg_replace_callback(
			'/<iframe([^>]+)>/i',
			array( $this, 'inject_lazy_iframe' ),
			$html
		);

		return $html;
	}

	/**
	 * Injects loading="lazy" (or LCP attributes for the first N images) into a single <img> tag.
	 *
	 * @param  array $matches Regex match array. $matches[1] is the attributes string.
	 * @return string         The complete <img> tag with the attribute(s) added.
	 */
	private function inject_lazy_img( $matches ) {
		$attrs = $matches[1];

		// Skip data-uri images (inline, no network request).
		if ( false !== stripos( $attrs, 'src="data:' ) ) {
			return $matches[0];
		}

		// Skip images explicitly excluded by a data attribute.
		if ( false !== stripos( $attrs, 'data-no-lazy' ) || false !== stripos( $attrs, 'data-skip-lazy' ) ) {
			return $matches[0];
		}

		// Skip images already handled by another plugin's own lazy-load
		// implementation (e.g. Slider Revolution, Smash Balloon) to avoid
		// double-processing, which breaks their JS-driven loading logic.
		if ( $this->is_foreign_lazy_managed( $attrs ) ) {
			return $matches[0];
		}

		// Branding/logo images (header logo, WP's get_custom_logo() output,
		// duplicate logos in mobile nav menus, etc.) shouldn't eat into the
		// "first N images" budget meant for real above-the-fold content. They're
		// small and safe to load eagerly regardless of position, but counting
		// them pushes the actual LCP element — what Lighthouse measures —
		// further down the sequence, forcing users to guess a much higher count
		// than the page structure actually needs.
		if ( $this->is_logo_image( $attrs ) ) {
			if ( false !== stripos( $attrs, 'loading=' ) ) {
				return $matches[0];
			}
			return '<img loading="eager"' . $attrs . '>';
		}

		// Small thumbnails (related-post icons, "trending" sidebar minis,
		// avatars — anything at or under WordPress core's built-in 150x150
		// "thumbnail" size) are never the LCP element, but a duplicate small
		// crop of the real hero image often appears earlier in the raw HTML
		// than the full-size one (e.g. a "popular posts" list above the main
		// content grid). Skip them from the counter so they don't burn the
		// eager-loading budget meant for the actual large hero image — and
		// don't force them eager either, they don't need it.
		if ( $this->is_small_thumbnail( $attrs ) ) {
			if ( false !== stripos( $attrs, 'loading=' ) ) {
				return $matches[0];
			}
			return '<img loading="lazy"' . $attrs . '>';
		}

		// --- LCP Priority Logic ---
		++$this->img_counter;

		if ( $this->img_counter <= $this->lcp_image_count ) {
			// This is an above-the-fold image. Make it load eagerly with high priority.
			// First, remove any existing loading attribute.
			$attrs = preg_replace( '/\s*loading\s*=\s*["\'][^"\']*["\']/i', '', $attrs );
			// Remove any existing fetchpriority attribute.
			$attrs = preg_replace( '/\s*fetchpriority\s*=\s*["\'][^"\']*["\']/i', '', $attrs );
			// Remove decoding="async" as it can delay the LCP image.
			$attrs = preg_replace( '/\s*decoding\s*=\s*["\'][^"\']*["\']/i', '', $attrs );

			$eager_attrs = ' loading="eager" fetchpriority="high" decoding="sync"';

			// First image gets the highest priority.
			if ( $this->img_counter === 1 ) {
				return '<img' . $eager_attrs . $attrs . '>';
			}
			// Other above-fold images: eager but no special fetchpriority.
			return '<img loading="eager"' . $attrs . '>';
		}

		// --- Standard Lazy Load for below-the-fold images ---
		if ( false !== stripos( $attrs, 'loading=' ) ) {
			// Already has a loading attribute — leave it alone.
			return $matches[0];
		}

		return '<img loading="lazy"' . $attrs . '>';
	}

	/**
	 * Detects branding/logo images so they don't consume the "first N images"
	 * LCP budget meant for real above-the-fold content.
	 *
	 * @param  string $attrs The raw attribute string of an <img> tag.
	 * @return bool
	 */
	private function is_logo_image( $attrs ) {
		// WordPress core's get_custom_logo() always outputs class="custom-logo".
		if ( false !== stripos( $attrs, 'custom-logo' ) ) {
			return true;
		}
		// Common theme convention: a "logo" class on the image itself.
		if ( preg_match( '/class\s*=\s*["\'][^"\']*\blogo\b[^"\']*["\']/i', $attrs ) ) {
			return true;
		}
		// Fallback: the filename itself says "logo" (covers themes that don't
		// use a matching class, e.g. logo@2x.png / logo_light@2x.png).
		if ( preg_match( '/(?:src|data-src)\s*=\s*["\'][^"\']*logo[^"\']*["\']/i', $attrs ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Detects small (<=150x150, WordPress core's built-in "thumbnail" size)
	 * images so they don't consume the "first N images" LCP budget — a
	 * duplicate small crop of a post's featured image (related posts, popular
	 * posts widgets, avatars) often appears earlier in the HTML than the
	 * full-size hero rendition, which would otherwise eat the budget before
	 * the real LCP element is even reached.
	 *
	 * @param  string $attrs The raw attribute string of an <img> tag.
	 * @return bool
	 */
	private function is_small_thumbnail( $attrs ) {
		$threshold = 150;

		if ( preg_match( '/\bwidth\s*=\s*["\']?(\d+)/i', $attrs, $w )
			&& preg_match( '/\bheight\s*=\s*["\']?(\d+)/i', $attrs, $h ) ) {
			$width  = (int) $w[1];
			$height = (int) $h[1];
			if ( $width > 0 && $width <= $threshold && $height > 0 && $height <= $threshold ) {
				return true;
			}
		}

		// WordPress's standard generated-size filename suffix, e.g. "-110x110.png".
		if ( preg_match( '/-(\d{2,4})x(\d{2,4})\.(?:png|jpe?g|webp|gif|avif)(?:["\'?]|$)/i', $attrs, $m ) ) {
			if ( (int) $m[1] <= $threshold && (int) $m[2] <= $threshold ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Injects loading="lazy" into a single <iframe> tag if missing.
	 *
	 * @param  array $matches Regex match array. $matches[1] is the attributes string.
	 * @return string         The complete <iframe> tag with the attribute added.
	 */
	private function inject_lazy_iframe( $matches ) {
		$attrs = $matches[1];

		// Skip if already has a loading attribute.
		if ( false !== stripos( $attrs, 'loading=' ) ) {
			return $matches[0];
		}

		// Skip iframes marked to exclude (e.g., payment widgets, critical content).
		if ( false !== stripos( $attrs, 'data-no-lazy' ) || false !== stripos( $attrs, 'data-skip-lazy' ) ) {
			return $matches[0];
		}

		if ( $this->is_foreign_lazy_managed( $attrs ) ) {
			return $matches[0];
		}

		return '<iframe loading="lazy"' . $attrs . '>';
	}

	/**
	 * Detects markup patterns used by popular slider/lazy-load plugins so
	 * CacheLoop doesn't double-process images they already manage themselves
	 * via their own JS — a known source of broken sliders and background
	 * images when a caching plugin's lazy load "fights" the plugin's own.
	 *
	 * @param  string $attrs The raw attribute string of an <img>/<iframe> tag.
	 * @return bool
	 */
	private function is_foreign_lazy_managed( $attrs ) {
		// Slider Revolution: class="rev-slidebg" combined with a parallax data
		// attribute marks a slide background image driven entirely by its own JS.
		if ( preg_match( '/class\s*=\s*["\'][^"\']*\brev-slidebg\b[^"\']*["\']/i', $attrs )
			&& preg_match( '/\sdata-(?:bg)?parallax\s*=/i', $attrs ) ) {
			return true;
		}

		// Slider Revolution (alternate markup): data-lazyload points at the
		// real image, the visible src is a placeholder swapped in by its JS.
		if ( preg_match( '/\sdata-lazyload\s*=\s*["\']/i', $attrs ) ) {
			return true;
		}

		// Any other plugin already implementing its own lazy-loading via a
		// data-*lazy-src style attribute (e.g. data-lazy-src, data-gg-lazy-src).
		if ( preg_match( '/\sdata-[a-z-]*lazy-src\s*=\s*["\']/i', $attrs ) ) {
			return true;
		}

		// Smash Balloon Instagram/Social Feed placeholder image.
		if ( preg_match( '/instagram-feed\/img\/placeholder\.png/i', $attrs ) ) {
			return true;
		}

		return false;
	}
}
