<?php

/**
 * Lazy Loading Module
 *
 * Implements lazy loading for images, iframes, and videos
 *
 * @package ProRank\SEO\Modules\Performance
 * @since   1.0.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Modules\Performance;

use ProRank\SEO\Core\Compatibility\PerformanceCompatibilityRegistry;
use ProRank\SEO\Modules\BaseModule;
use ProRank\SEO\Plugin;
use DOMDocument;
use DOMXPath;

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


/**
 * LazyLoadModule class
 */


class LazyLoadModule extends BaseModule {

    /**
     * Required tier for this module
     */
    protected string $feature_tier = 'free';

    /**
     * Excluded classes/patterns cache
     *
     * @var array
     */
    private array $exclusions = [];

    /**
     * JavaScript added flag
     *
     * @var bool
     */
    private bool $js_added = false;

    /**
     * Global image counter for tracking above-the-fold images
     *
     * @var int
     */
    private static int $image_count = 0;

    /**
     * Number of images to skip lazy loading (above the fold)
     * WP Rocket and Perfmatters typically skip 3-5 images
     *
     * @var int
     */
    private const SKIP_FIRST_IMAGES = 3;

    /**
     * Minimum width for fetchpriority LCP candidate.
     *
     * @var int
     */
    private const LCP_MIN_WIDTH = 320;

    /**
     * Minimum height for fetchpriority LCP candidate.
     *
     * @var int
     */
    private const LCP_MIN_HEIGHT = 180;

    /**
     * Minimum area for fetchpriority LCP candidate.
     *
     * @var int
     */
    private const LCP_MIN_AREA = 100000;

    /**
     * Whether we've already added fetchpriority to an image
     *
     * @var bool
     */
    private static bool $fetchpriority_added = false;
    
    /**
     * Constructor
     */
    public function __construct() {
        $this->slug = 'lazyload';
        $this->name = 'Lazy Loading';
        $this->description = 'Lazy load images, iframes, and videos for better performance';
$this->parent_slug = 'performance';
    }

    /**
     * Get setting from unified image optimization settings
     * Overrides BaseModule to read from prorank_image_optimization_settings
     *
     * @param string $key Setting key
     * @param mixed $default Default value
     * @return mixed
     */
    protected function get_setting(string $key, $default = null) {
        // Try to get from central settings first (AI Doctor updates these)
        try {
            $plugin = Plugin::get_instance();
            if ($plugin->is_initialized()) {
                $settings_manager = $plugin->settings();
                
                // Map short keys to performance group keys
                $perf_key_map = [
                    'images' => 'lazy_load_images',
                    'iframes' => 'lazy_load_iframes',
                    'videos' => 'lazy_load_videos',
                ];
                
                if (isset($perf_key_map[$key])) {
                    $val = $settings_manager->get('performance', $perf_key_map[$key]);
                    if ($val !== null) {
                        return $val;
                    }
                }
            }
        } catch (\Exception $e) {
            // Fallback to legacy
        }

        static $settings = null;
        if ($settings === null) {
            $settings = get_option('prorank_image_optimization_settings', []);
        }
        // Map short keys to full keys
        $key_map = [
            'images' => 'lazyload_images',
            'iframes' => 'lazyload_iframes',
            'videos' => 'lazyload_videos',
            'threshold' => 'lazyload_threshold',
            'exclude' => 'exclude_paths',
        ];
        $mapped_key = $key_map[$key] ?? $key;
        $value = $settings[$mapped_key] ?? $default;

        if (in_array($mapped_key, ['lazyload_images', 'lazyload_iframes', 'lazyload_videos'], true)) {
            $legacy_enabled = !empty($settings['lazyload_enabled']);
            $granular_enabled = !empty($settings['lazyload_images']) || !empty($settings['lazyload_iframes']) || !empty($settings['lazyload_videos']);

            if ($legacy_enabled && !$granular_enabled) {
                return true;
            }
        }

        return $value;
    }
    
    /**
     * Initialize module hooks
     *
     * @return void
     */
    public function init_hooks(): void {
        // Check if module should run
        if (!$this->should_run()) {
            return;
        }
        
        // Hook into content filtering
        add_filter('the_content', [$this, 'process_content'], 999);
        add_filter('widget_text', [$this, 'process_content'], 999);
        add_filter('get_avatar', [$this, 'process_avatar'], 999);
        add_filter('post_thumbnail_html', [$this, 'process_content'], 999);
        
        // Hook into image attributes
        add_filter('wp_get_attachment_image_attributes', [$this, 'add_lazy_load_attributes'], 10, 3);

        if ($this->should_process_full_page_buffer()) {
            add_action('template_redirect', [$this, 'start_output_buffer'], 1);
        }
        
        // Add frontend JavaScript for any feature that needs runtime observers.
        if (
            $this->get_setting('background_images', false) ||
            $this->get_setting('iframes', false) ||
            $this->get_setting('videos', false)
        ) {
            add_action('wp_footer', [$this, 'add_lazy_load_script'], 20);
        }
    }
    
    /**
     * Check if module should run
     *
     * @return bool
     */
    private function should_run(): bool {
        // Module is available for free tier
        if (!$this->is_enabled()) {
            return false;
        }
        
        // Don't run in admin, feeds, or for AMP
        if (is_admin() || is_feed() || $this->is_amp()) {
            return false;
        }

        if ($this->is_current_request_excluded()) {
            return false;
        }
        
        // Check if any lazy loading is enabled
        return (bool) $this->get_setting('images', false) ||
               $this->get_setting('iframes', false) ||
               $this->get_setting('videos', false) ||
               $this->get_setting('background_images', false);
    }
    
    /**
     * Process content to add lazy loading
     *
     * @param string $content Content to process
     * @return string Modified content
     */
    public function process_content(string $content): string {
        if (empty($content)) {
            return $content;
        }
        
        // Use DOMDocument to parse HTML
        $dom = $this->get_dom_document($content);
        if (!$dom) {
            return $content;
        }
        
        $xpath = new DOMXPath($dom);
        $modified = false;
        
        // Process images
        if ($this->get_setting('images', false)) {
            $modified = $this->process_images($dom, $xpath) || $modified;
        }
        
        // Process iframes
        if ($this->get_setting('iframes', false)) {
            $modified = $this->process_iframes($dom, $xpath) || $modified;
        }
        
        // Process videos
        if ($this->get_setting('videos', false)) {
            $modified = $this->process_videos($dom, $xpath) || $modified;
        }
        
        // Process background images
        if ($this->get_setting('background_images', false)) {
            $modified = $this->process_background_images($dom, $xpath) || $modified;
        }
        
        // Return original content if nothing was modified
        if (!$modified) {
            return $content;
        }
        
        // Get the processed HTML
        $body = $dom->getElementsByTagName('body')->item(0);
        if (!$body) {
            return $content;
        }
        
        $processed = '';
        foreach ($body->childNodes as $child) {
            $processed .= $dom->saveHTML($child);
        }
        
        return $processed ?: $content;
    }

    /**
     * Start full-page output buffering for builder-managed layouts.
     *
     * @return void
     */
    public function start_output_buffer(): void {
        if (!$this->is_frontend_output_context() || !$this->should_process_full_page_buffer()) {
            return;
        }

        ob_start([$this, 'process_output_buffer']);
    }

    /**
     * Process full-page HTML for lazy features that builders bypass.
     *
     * @param string $buffer
     * @return string
     */
    public function process_output_buffer(string $buffer): string {
        if (!$this->is_frontend_output_context()) {
            return $buffer;
        }

        if (!preg_match('/<html/i', $buffer)) {
            return $buffer;
        }

        if (
            !$this->get_setting('iframes', false)
            && !$this->get_setting('videos', false)
            && !$this->get_setting('background_images', false)
        ) {
            return $buffer;
        }

        if (
            stripos($buffer, '<iframe') === false
            && stripos($buffer, '<video') === false
            && stripos($buffer, 'background-image') === false
        ) {
            return $buffer;
        }

        $dom = $this->get_full_page_dom_document($buffer);
        if (!$dom) {
            return $buffer;
        }

        $xpath = new DOMXPath($dom);
        $modified = false;

        if ($this->get_setting('iframes', false)) {
            $modified = $this->process_iframes($dom, $xpath) || $modified;
        }

        if ($this->get_setting('videos', false)) {
            $modified = $this->process_videos($dom, $xpath) || $modified;
        }

        if ($this->get_setting('background_images', false)) {
            $modified = $this->process_background_images($dom, $xpath) || $modified;
        }

        if (!$modified) {
            return $buffer;
        }

        $processed = $dom->saveHTML();
        if (!is_string($processed) || $processed === '') {
            return $buffer;
        }

        return $this->inject_lazy_load_script_markup($processed);
    }
    
    /**
     * Process avatar images
     *
     * @param string $avatar Avatar HTML
     * @return string Modified avatar HTML
     */
    public function process_avatar(string $avatar): string {
        if (!$this->get_setting('images', false)) {
            return $avatar;
        }
        
        return $this->process_content($avatar);
    }
    
    /**
     * Add lazy load attributes to image
     *
     * @param array $attributes Image attributes
     * @param object $attachment Attachment object
     * @param string|array $size Image size
     * @return array Modified attributes
     */
    public function add_lazy_load_attributes(array $attributes, $attachment, $size): array {
        if (!$this->get_setting('images', false)) {
            return $attributes;
        }

        // Respect existing high-priority assignment from theme/plugins/modules.
        if (isset($attributes['fetchpriority']) && strtolower((string) $attributes['fetchpriority']) === 'high') {
            self::$fetchpriority_added = true;
            if (isset($attributes['loading']) && strtolower((string) $attributes['loading']) === 'lazy') {
                $attributes['loading'] = 'eager';
            }
        }

        // Increment global image counter
        self::$image_count++;

        // Skip if excluded
        $class = $attributes['class'] ?? '';
        if ($this->is_excluded($class) || $this->is_above_fold_class($class)) {
            return $attributes;
        }

        // Assign high priority once to the first meaningful LCP candidate.
        if (
            !self::$fetchpriority_added
            && !$this->should_defer_fetchpriority_assignment_to_lcp_modules()
            && $this->is_valid_lcp_candidate_from_attributes($attributes)
        ) {
            $attributes['fetchpriority'] = 'high';
            // Remove lazy loading from LCP candidate
            unset($attributes['loading']);
            self::$fetchpriority_added = true;
            return $attributes;
        }

        // Skip lazy loading for first N images (above the fold)
        if (self::$image_count <= self::SKIP_FIRST_IMAGES) {
            // Don't add lazy loading to above-the-fold images
            unset($attributes['loading']);
            return $attributes;
        }

        // Skip if already has loading attribute
        if (isset($attributes['loading'])) {
            return $attributes;
        }

        // Add lazy loading for images below the fold
        $attributes['loading'] = 'lazy';

        return $attributes;
    }

    /**
     * Check if class indicates an above-the-fold element
     *
     * @param string $class Class string
     * @return bool
     */
    private function is_above_fold_class(string $class): bool {
        $above_fold_classes = [
            'custom-logo',
            'site-logo',
            'header-logo',
            'logo',
            'hero-image',
            'banner-image',
            'featured-image',
            'wp-post-image', // Featured images are often above fold
        ];

        foreach ($above_fold_classes as $atf_class) {
            if (strpos($class, $atf_class) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Determine whether an attachment image attributes payload is a valid
     * candidate for fetchpriority high.
     *
     * @param array $attributes
     * @return bool
     */
    private function is_valid_lcp_candidate_from_attributes(array $attributes): bool {
        $class = strtolower((string) ($attributes['class'] ?? ''));
        $alt = strtolower((string) ($attributes['alt'] ?? ''));
        $src = strtolower((string) ($attributes['src'] ?? ''));

        if ($src === '') {
            return false;
        }

        if ($this->is_likely_decorative_image($class, $alt, $src)) {
            return false;
        }

        $width = isset($attributes['width']) ? (int) $attributes['width'] : 0;
        $height = isset($attributes['height']) ? (int) $attributes['height'] : 0;

        if (($width <= 0 || $height <= 0) && $src !== '') {
            $dimensions = $this->get_image_dimensions($src);
            if (is_array($dimensions)) {
                $width = (int) ($dimensions['width'] ?? $width);
                $height = (int) ($dimensions['height'] ?? $height);
            }
        }

        return !$this->is_dimensions_too_small_for_lcp($width, $height);
    }

    /**
     * Determine whether a DOM image element is a valid candidate for
     * fetchpriority high.
     *
     * @param \DOMElement $img
     * @return bool
     */
    private function is_valid_lcp_candidate_from_dom(\DOMElement $img): bool {
        $class = strtolower((string) $img->getAttribute('class'));
        $alt = strtolower((string) $img->getAttribute('alt'));
        $src = strtolower((string) $img->getAttribute('src'));

        if ($src === '') {
            return false;
        }

        if ($this->is_likely_decorative_image($class, $alt, $src)) {
            return false;
        }

        $width = (int) $img->getAttribute('width');
        $height = (int) $img->getAttribute('height');

        if (($width <= 0 || $height <= 0) && $src !== '') {
            $dimensions = $this->get_image_dimensions($src);
            if (is_array($dimensions)) {
                $width = (int) ($dimensions['width'] ?? $width);
                $height = (int) ($dimensions['height'] ?? $height);
            }
        }

        return !$this->is_dimensions_too_small_for_lcp($width, $height);
    }

    /**
     * Check if image looks decorative and should never receive fetchpriority.
     *
     * @param string $class
     * @param string $alt
     * @param string $src
     * @return bool
     */
    private function is_likely_decorative_image(string $class, string $alt, string $src): bool {
        $haystack = strtolower(trim($class . ' ' . $alt . ' ' . $src));
        if ($haystack === '') {
            return false;
        }

        foreach ([
            'logo',
            'site-icon',
            'favicon',
            'avatar',
            'gravatar',
            'emoji',
            'smiley',
            'icon-',
            '/icons/',
            'spinner',
            'loader',
            'badge',
        ] as $token) {
            if (strpos($haystack, $token) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Check minimum dimensions for fetchpriority LCP candidacy.
     *
     * @param int $width
     * @param int $height
     * @return bool
     */
    private function is_dimensions_too_small_for_lcp(int $width, int $height): bool {
        if ($width <= 0 || $height <= 0) {
            return false;
        }

        if ($width < self::LCP_MIN_WIDTH || $height < self::LCP_MIN_HEIGHT) {
            return true;
        }

        return ($width * $height) < self::LCP_MIN_AREA;
    }
    
    /**
     * Process images in DOM
     *
     * @param DOMDocument $dom DOM document
     * @param DOMXPath $xpath XPath object
     * @return bool Whether any images were modified
     */
    private function process_images(DOMDocument $dom, DOMXPath $xpath): bool {
        $images = $xpath->query('//img');
        $modified = false;

        foreach ($images as $img) {
            self::$image_count++;

            // Check exclusions
            $class = $img->getAttribute('class');
            if ($this->is_excluded($class) || $this->is_above_fold_class($class)) {
                // Still add dimensions even for excluded images (CLS fix)
                if ($this->add_missing_dimensions($img)) {
                    $modified = true;
                }
                continue;
            }

            // Skip images without src
            if (!$img->hasAttribute('src') || empty($img->getAttribute('src'))) {
                continue;
            }

            // Add missing width/height attributes (CLS fix)
            if ($this->add_missing_dimensions($img)) {
                $modified = true;
            }

            if (
                $img->hasAttribute('fetchpriority')
                && strtolower((string) $img->getAttribute('fetchpriority')) === 'high'
            ) {
                self::$fetchpriority_added = true;
                if (
                    $img->hasAttribute('loading')
                    && strtolower((string) $img->getAttribute('loading')) === 'lazy'
                ) {
                    $img->setAttribute('loading', 'eager');
                    $modified = true;
                }
            }

            // Assign high priority only once to the first meaningful image.
            if (
                !self::$fetchpriority_added
                && !$this->should_defer_fetchpriority_assignment_to_lcp_modules()
                && !$img->hasAttribute('fetchpriority')
                && $this->is_valid_lcp_candidate_from_dom($img)
            ) {
                $img->setAttribute('fetchpriority', 'high');
                self::$fetchpriority_added = true;
                // Don't add lazy loading to LCP candidate
                if ($img->hasAttribute('loading')) {
                    $img->removeAttribute('loading');
                }
                $modified = true;
                continue;
            }

            // Skip lazy loading for first N images above the fold globally.
            if (self::$image_count <= self::SKIP_FIRST_IMAGES) {
                continue;
            }

            // Add loading attribute for below-fold images (if not already set)
            if (!$img->hasAttribute('loading')) {
                $img->setAttribute('loading', 'lazy');
                $modified = true;
            }
        }

        return $modified;
    }

    /**
     * Avoid generic first-image fetchpriority when a dedicated LCP target exists.
     *
     * @return bool
     */
    private function should_defer_fetchpriority_assignment_to_lcp_modules(): bool {
        if (!empty($GLOBALS['prorank_lcp_image_url']) && is_string($GLOBALS['prorank_lcp_image_url'])) {
            return true;
        }

        return $this->get_known_lcp_image_url_for_current_request() !== '';
    }

    /**
     * Resolve the current request's known LCP asset from preload, optimizer, or beacon data.
     *
     * @return string
     */
    private function get_known_lcp_image_url_for_current_request(): string {
        $preloaded = $GLOBALS['prorank_preloaded_urls'] ?? [];
        if (is_array($preloaded)) {
            foreach ($preloaded as $entry) {
                if (!is_string($entry) || trim($entry) === '') {
                    continue;
                }

                $candidate = trim((string) explode('|', $entry, 2)[0]);
                if (preg_match('/\.(avif|webp|jpe?g|png|gif|svg)(\?|$)/i', $candidate) === 1) {
                    return $candidate;
                }
            }
        }

        $optimizer_data = get_option('prorank_lcp_optimizer_data', []);
        if (is_array($optimizer_data)) {
            $page_key = $this->get_current_lcp_page_template_key();
            $entry = $page_key !== '' ? ($optimizer_data[$page_key] ?? null) : null;
            if (is_array($entry) && !empty($entry['url']) && is_string($entry['url'])) {
                return esc_url_raw($entry['url']);
            }
        }

        $beacon_data = get_option('prorank_lcp_beacon_data', []);
        if (is_array($beacon_data)) {
            $pattern = $this->get_current_lcp_url_pattern();
            $entry = $pattern !== '' ? ($beacon_data[$pattern] ?? null) : null;
            if (is_array($entry)) {
                $background = isset($entry['background_image']) && is_string($entry['background_image']) ? esc_url_raw($entry['background_image']) : '';
                if ($background !== '') {
                    return $background;
                }

                $src = isset($entry['src']) && is_string($entry['src']) ? esc_url_raw($entry['src']) : '';
                if ($src !== '') {
                    return $src;
                }
            }
        }

        return '';
    }

    /**
     * Match the LCP optimizer page-key scheme for the current request.
     *
     * @return string
     */
    private function get_current_lcp_page_template_key(): string {
        if (is_front_page()) {
            return 'front_page';
        }
        if (is_home()) {
            return 'blog_home';
        }
        if (is_singular('post')) {
            return 'single_post';
        }
        if (is_singular('page')) {
            $template = get_page_template_slug();
            return 'page_' . ($template ?: 'default');
        }
        if (is_singular()) {
            return 'single_' . get_post_type();
        }
        if (is_archive()) {
            if (is_category()) {
                return 'archive_category';
            }
            if (is_tag()) {
                return 'archive_tag';
            }
            if (is_author()) {
                return 'archive_author';
            }
            return 'archive_' . (get_post_type() ?: 'post');
        }
        if (is_search()) {
            return 'search';
        }
        if (is_404()) {
            return '404';
        }

        return 'other';
    }

    /**
     * Match the beacon module URL-pattern scheme for the current request.
     *
     * @return string
     */
    private function get_current_lcp_url_pattern(): string {
        $url = $this->get_current_request_url();
        if ($url === '') {
            return '';
        }

        $parsed = wp_parse_url($url);
        $path = $parsed['path'] ?? '/';
        $path = trim((string) $path, '/');
        if ($path === '') {
            return 'home';
        }

        $path = preg_replace('/\/\d+\//', '/*/', $path);
        $path = preg_replace('/\/\d+$/', '/*', $path);
        $parts = explode('/', $path);
        $parts = array_slice($parts, 0, 3);

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

    /**
     * Build the current request URL for beacon-style lookup.
     *
     * @return string
     */
    private function get_current_request_url(): string {
        $protocol = is_ssl() ? 'https' : 'http';
        $host = $_SERVER['HTTP_HOST'] ?? '';
        $uri = $_SERVER['REQUEST_URI'] ?? '';

        if (!is_string($host) || !is_string($uri) || $host === '') {
            return '';
        }

        return $protocol . '://' . $host . $uri;
    }

    /**
     * Add missing width/height attributes to image to prevent CLS
     *
     * @param \DOMElement $img Image element
     * @return bool Whether dimensions were added
     */
    private function add_missing_dimensions(\DOMElement $img): bool {
        // Skip if both dimensions already exist
        if ($img->hasAttribute('width') && $img->hasAttribute('height')) {
            return false;
        }

        if ($this->should_skip_intrinsic_dimensions_for_image($img)) {
            return false;
        }

        $src = $img->getAttribute('src');
        if (empty($src)) {
            return false;
        }

        // Get dimensions from various sources
        $dimensions = $this->get_image_dimensions($src);
        if (!$dimensions) {
            return false;
        }

        $modified = false;

        // Add width if missing
        if (!$img->hasAttribute('width') && $dimensions['width'] > 0) {
            $img->setAttribute('width', (string) $dimensions['width']);
            $modified = true;
        }

        // Add height if missing
        if (!$img->hasAttribute('height') && $dimensions['height'] > 0) {
            $img->setAttribute('height', (string) $dimensions['height']);
            $modified = true;
        }

        return $modified;
    }

    /**
     * Determine whether intrinsic dimensions should be skipped for this image.
     *
     * @param \DOMElement $img
     * @return bool
     */
    private function should_skip_intrinsic_dimensions_for_image(\DOMElement $img): bool {
        $markers = PerformanceCompatibilityRegistry::get_image_dimension_skip_markers();
        foreach ($markers as $marker) {
            if ($marker !== '' && $img->hasAttribute($marker)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Get image dimensions from various sources
     *
     * @param string $src Image source URL
     * @return array|null ['width' => int, 'height' => int] or null
     */
    private function get_image_dimensions(string $src): ?array {
        // Cache dimensions to avoid repeated lookups
        static $cache = [];

        if (isset($cache[$src])) {
            return $cache[$src];
        }

        $dimensions = null;

        // Try to get attachment ID from URL
        $attachment_id = attachment_url_to_postid($src);
        if ($attachment_id) {
            $metadata = wp_get_attachment_metadata($attachment_id);
            if (!empty($metadata['width']) && !empty($metadata['height'])) {
                $dimensions = [
                    'width' => (int) $metadata['width'],
                    'height' => (int) $metadata['height'],
                ];
            }
        }

        // If not found, try to get from local file
        if (!$dimensions) {
            $local_path = $this->url_to_local_path($src);
            if ($local_path && file_exists($local_path)) {
                $upload_dir = wp_upload_dir();
                $base_dir = wp_normalize_path($upload_dir['basedir'] ?? '');
                $normalized_path = wp_normalize_path($local_path);
                if ($base_dir === '' || strpos($normalized_path, $base_dir) !== 0) {
                    $cache[$src] = null;
                    return $cache[$src];
                }

                $size = wp_getimagesize($local_path);
                if ($size && $size[0] > 0 && $size[1] > 0) {
                    $dimensions = [
                        'width' => (int) $size[0],
                        'height' => (int) $size[1],
                    ];
                }
            }
        }

        $cache[$src] = $dimensions;
        return $dimensions;
    }

    /**
     * Convert URL to local file path
     *
     * @param string $url Image URL
     * @return string|null Local file path or null
     */
    private function url_to_local_path(string $url): ?string {
        // Get upload directory info
        $upload_dir = wp_upload_dir();

        // Check if URL is from uploads directory
        if (strpos($url, $upload_dir['baseurl']) === 0) {
            return str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $url);
        }

        // Check if URL is from the site
        $site_url = site_url();
        $abspath = ABSPATH;
        if (strpos($url, $site_url) === 0) {
            return str_replace($site_url, rtrim($abspath, '/'), $url);
        }

        // Handle relative URLs
        if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
            return rtrim($abspath, '/') . $url;
        }

        return null;
    }
    
    /**
     * Process iframes in DOM
     *
     * @param DOMDocument $dom DOM document
     * @param DOMXPath $xpath XPath object
     * @return bool Whether any iframes were modified
     */
    private function process_iframes(DOMDocument $dom, DOMXPath $xpath): bool {
        $iframes = $xpath->query('//iframe[@src]');
        $modified = false;
        
        foreach ($iframes as $iframe) {
            // Check exclusions
            $class = $iframe->getAttribute('class');
            $src = $iframe->getAttribute('src');
            
            if ($this->is_excluded($class) || $this->is_excluded($src)) {
                continue;
            }
            
            // Skip iframes without src
            if (empty($src)) {
                continue;
            }

            $loading = strtolower((string) $iframe->getAttribute('loading'));
            $is_map_embed = $this->is_map_iframe_src($src);

            if (!$iframe->hasAttribute('loading')) {
                $iframe->setAttribute('loading', 'lazy');
                $modified = true;
            } elseif ($is_map_embed && $loading === 'eager') {
                // Many map widgets save loading="eager" by default; converting to
                // lazy prevents below-the-fold map stacks from affecting initial
                // render and third-party cache-lifetime audits.
                $iframe->setAttribute('loading', 'lazy');
                $modified = true;
            }

            if (
                $this->should_defer_iframe_src($src)
                && $iframe->getAttribute('loading') === 'lazy'
                && !$iframe->hasAttribute('data-prorank-lazy-iframe-src')
            ) {
                $iframe_lazy_mode = $this->resolve_iframe_lazy_mode($src, $is_map_embed);
                $iframe->setAttribute('data-prorank-lazy-iframe-src', $src);
                $iframe->setAttribute('data-prorank-lazy-iframe', $iframe_lazy_mode);
                $iframe->setAttribute('src', 'about:blank');

                $classes = trim((string) $iframe->getAttribute('class'));
                if (strpos(' ' . $classes . ' ', ' prorank-lazy-iframe ') === false) {
                    $iframe->setAttribute('class', trim($classes . ' prorank-lazy-iframe'));
                }

                $modified = true;
                $this->js_added = true;
            }
        }
        
        return $modified;
    }
    
    /**
     * Process videos in DOM
     *
     * @param DOMDocument $dom DOM document
     * @param DOMXPath $xpath XPath object
     * @return bool Whether any videos were modified
     */
    private function process_videos(DOMDocument $dom, DOMXPath $xpath): bool {
        // For video elements, we'll add preload="none" to prevent automatic loading
        $videos = $xpath->query('//video[not(@preload) or @preload="auto" or @preload="metadata"]');
        $modified = false;
        
        foreach ($videos as $video) {
            // Check exclusions
            $class = $video->getAttribute('class');
            if ($this->is_excluded($class)) {
                continue;
            }
            
            // Set preload to none
            $video->setAttribute('preload', 'none');
            
            // Add data attribute for JavaScript enhancement
            $video->setAttribute('data-prorank-lazy', 'true');
            
            $modified = true;
        }
        
        return $modified;
    }
    
    /**
     * Process background images in DOM
     *
     * @param DOMDocument $dom DOM document
     * @param DOMXPath $xpath XPath object
     * @return bool Whether any elements were modified
     */
    private function process_background_images(DOMDocument $dom, DOMXPath $xpath): bool {
        // Find elements with inline background-image styles
        $elements = $xpath->query('//*[contains(@style, "background-image")]');
        $modified = false;
        
        foreach ($elements as $element) {
            $style = $element->getAttribute('style');
            
            // Extract background-image URL
            if (preg_match('/background-image\s*:\s*url\s*\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)/i', $style, $matches)) {
                $url = $matches[1];
                
                // Check exclusions
                $class = $element->getAttribute('class');
                if ($this->is_excluded($class) || $this->is_excluded($url)) {
                    continue;
                }
                
                // Move background-image to data attribute
                $element->setAttribute('data-prorank-lazy-bg', $url);
                
                // Remove background-image from style
                $new_style = preg_replace('/background-image\s*:\s*url\s*\([^)]+\)\s*;?/i', '', $style);
                $element->setAttribute('style', trim($new_style));
                
                // Add marker class
                $classes = $element->getAttribute('class');
                $element->setAttribute('class', trim($classes . ' prorank-lazy-bg'));
                
                $modified = true;
                $this->js_added = true;
            }
        }
        
        return $modified;
    }
    
    /**
     * Add lazy load JavaScript
     *
     * @return void
     */
    public function add_lazy_load_script(): void {
        if (!$this->js_added) {
            return;
        }

        $root_margin = $this->get_observer_root_margin();

        ?>
        <script id="prorank-lazy-load">
        (function() {
            'use strict';

            var restoreLazyIframe = function(frame) {
                var src = frame.getAttribute('data-prorank-lazy-iframe-src');
                if (!src) {
                    return;
                }

                frame.setAttribute('src', src);
                frame.removeAttribute('data-prorank-lazy-iframe-src');
                frame.removeAttribute('data-prorank-lazy-iframe');
                frame.classList.remove('prorank-lazy-iframe');
            };

            var attachInteractionLoader = function(frames) {
                if (!frames || !frames.length) {
                    return;
                }

                var loaded = false;
                var events = ['scroll', 'touchstart', 'mousemove', 'keydown', 'click'];
                var loadAll = function() {
                    if (loaded) {
                        return;
                    }
                    loaded = true;
                    frames.forEach(function(frame) {
                        restoreLazyIframe(frame);
                    });
                    events.forEach(function(evt) {
                        window.removeEventListener(evt, loadAll, true);
                    });
                };

                events.forEach(function(evt) {
                    window.addEventListener(evt, loadAll, { passive: true, once: true, capture: true });
                });
            };

            var onReady = function(callback) {
                if (document.readyState === 'loading') {
                    document.addEventListener('DOMContentLoaded', callback);
                    return;
                }
                callback();
            };
            
            // Check for IntersectionObserver support
            if (!('IntersectionObserver' in window)) {
                // Fallback: load all background images immediately
                var elements = document.querySelectorAll('.prorank-lazy-bg[data-prorank-lazy-bg]');
                elements.forEach(function(el) {
                    var bg = el.getAttribute('data-prorank-lazy-bg');
                    if (bg) {
                        el.style.backgroundImage = 'url(' + bg + ')';
                        el.removeAttribute('data-prorank-lazy-bg');
                    }
                });

                var fallbackIframes = document.querySelectorAll('iframe[data-prorank-lazy-iframe-src]');
                var fallbackInteractionIframes = [];
                fallbackIframes.forEach(function(frame) {
                    if (frame.getAttribute('data-prorank-lazy-iframe') === 'interaction') {
                        fallbackInteractionIframes.push(frame);
                    } else {
                        restoreLazyIframe(frame);
                    }
                });

                attachInteractionLoader(fallbackInteractionIframes);
                return;
            }
            
            // Create intersection observer
            var lazyBgObserver = new IntersectionObserver(function(entries) {
                entries.forEach(function(entry) {
                    if (entry.isIntersecting) {
                        var el = entry.target;
                        var bg = el.getAttribute('data-prorank-lazy-bg');
                        
                        if (bg) {
                            el.style.backgroundImage = 'url(' + bg + ')';
                            el.removeAttribute('data-prorank-lazy-bg');
                            el.classList.remove('prorank-lazy-bg');
                            lazyBgObserver.unobserve(el);
                        }
                    }
                });
            }, {
                rootMargin: '<?php echo esc_js($root_margin); ?>',
                threshold: 0.01
            });
            
            // Observe all lazy background elements
            onReady(function() {
                var elements = document.querySelectorAll('.prorank-lazy-bg[data-prorank-lazy-bg]');
                elements.forEach(function(el) {
                    lazyBgObserver.observe(el);
                });
            });
            
            // Also handle videos with data-prorank-lazy
            var lazyVideoObserver = new IntersectionObserver(function(entries) {
                entries.forEach(function(entry) {
                    if (entry.isIntersecting) {
                        var video = entry.target;
                        if (video.getAttribute('data-prorank-lazy') === 'true') {
                            video.setAttribute('preload', 'metadata');
                            video.removeAttribute('data-prorank-lazy');
                            lazyVideoObserver.unobserve(video);
                        }
                    }
                });
            }, {
                rootMargin: '<?php echo esc_js($root_margin); ?>',
                threshold: 0.01
            });
            
            onReady(function() {
                var videos = document.querySelectorAll('video[data-prorank-lazy="true"]');
                videos.forEach(function(video) {
                    lazyVideoObserver.observe(video);
                });

                var lazyIframeObserver = new IntersectionObserver(function(entries) {
                    entries.forEach(function(entry) {
                        if (!entry.isIntersecting) {
                            return;
                        }

                        var frame = entry.target;
                        var src = frame.getAttribute('data-prorank-lazy-iframe-src');
                        if (!src) {
                            lazyIframeObserver.unobserve(frame);
                            return;
                        }

                        restoreLazyIframe(frame);
                        lazyIframeObserver.unobserve(frame);
                    });
                }, {
                    rootMargin: '<?php echo esc_js($root_margin); ?>',
                    threshold: 0.01
                });

                var iframes = document.querySelectorAll('iframe[data-prorank-lazy-iframe-src]');
                var interactionIframes = [];
                iframes.forEach(function(frame) {
                    if (frame.getAttribute('data-prorank-lazy-iframe') === 'interaction') {
                        interactionIframes.push(frame);
                        return;
                    }
                    lazyIframeObserver.observe(frame);
                });

                attachInteractionLoader(interactionIframes);
            });
        })();
        </script>
        <?php
    }

    /**
     * Defer very heavy third-party embeds that are safe to lazy-load when visible.
     *
     * @param string $src
     * @return bool
     */
    private function should_defer_iframe_src(string $src): bool {
        $src = strtolower(trim($src));
        if ($src === '') {
            return false;
        }

        foreach ([
            'youtube.com/embed',
            'youtube-nocookie.com/embed',
            'player.vimeo.com/video',
        ] as $needle) {
            if (strpos($src, $needle) !== false) {
                return true;
            }
        }

        if ($this->is_map_iframe_src($src)) {
            $allow_map_defer = (bool) apply_filters('prorank_lazy_defer_map_iframes', true, $src);
            if ($allow_map_defer) {
                return true;
            }
        }

        return false;
    }

    /**
     * Resolve iframe lazy mode.
     *
     * Default behavior is viewport-based lazy loading (`true`) for all iframes,
     * including maps, to prevent blank map sections that wait for interaction.
     * Advanced integrators can still override via filters.
     *
     * @param string $src
     * @param bool   $is_map_embed
     * @return string `true` or `interaction`
     */
    private function resolve_iframe_lazy_mode(string $src, bool $is_map_embed): string {
        $mode = apply_filters('prorank_lazy_iframe_mode', 'true', $src, $is_map_embed);
        $mode = is_string($mode) ? strtolower(trim($mode)) : 'true';

        if (!in_array($mode, ['true', 'interaction'], true)) {
            $mode = 'true';
        }

        if ($is_map_embed && $mode === 'interaction') {
            $allow_map_interaction = (bool) apply_filters(
                'prorank_lazy_map_iframe_interaction_mode',
                false,
                $src
            );
            if (!$allow_map_interaction) {
                $mode = 'true';
            }
        }

        return $mode;
    }

    /**
     * Check whether iframe source belongs to a heavy map provider.
     *
     * @param string $src
     * @return bool
     */
    private function is_map_iframe_src(string $src): bool {
        foreach ([
            'google.com/maps',
            'google.com/map',
            'maps.googleapis.com',
            'maps.google.',
            'maps-solutions',
            'storage.googleapis.com/maps-solutions',
            'openstreetmap.org',
            'mapbox.com',
            'bing.com/maps',
            'waze.com',
        ] as $needle) {
            if (strpos($src, $needle) !== false) {
                return true;
            }
        }

        return false;
    }
    
    /**
     * Get DOM document from HTML content
     *
     * @param string $content HTML content
     * @return DOMDocument|null
     */
    private function get_dom_document(string $content): ?DOMDocument {
        if (empty($content)) {
            return null;
        }

        try {
            $dom = new DOMDocument();

            // Suppress errors for HTML5 elements
            $libxml_previous = libxml_use_internal_errors(true);

            // Wrap content to ensure proper parsing
            $wrapped = '<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>' . $content . '</body></html>';

            // Load HTML
            $loaded = $dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);

            // Restore error handling
            libxml_clear_errors();
            libxml_use_internal_errors($libxml_previous);

            return $loaded ? $dom : null;
        } catch (\Throwable $e) {
            prorank_log('ProRank SEO: Failed to parse HTML for lazy loading - ' . $e->getMessage());
            return null;
        }
    }

    /**
     * Parse a full HTML document for builder/output-buffer processing.
     *
     * @param string $html
     * @return DOMDocument|null
     */
    private function get_full_page_dom_document(string $html): ?DOMDocument {
        if ($html === '') {
            return null;
        }

        try {
            $dom = new DOMDocument();
            $libxml_previous = libxml_use_internal_errors(true);
            $loaded = $dom->loadHTML($html);
            libxml_clear_errors();
            libxml_use_internal_errors($libxml_previous);

            return $loaded ? $dom : null;
        } catch (\Throwable $e) {
            prorank_log('ProRank SEO: Failed to parse full HTML for lazy loading - ' . $e->getMessage());
            return null;
        }
    }
    
    /**
     * Check if element/URL is excluded
     *
     * @param string $value Class string or URL to check
     * @return bool
     */
    private function is_excluded(string $value): bool {
        if (empty($value)) {
            return false;
        }
        
        // Get exclusions (cached)
        if (empty($this->exclusions)) {
            $excluded = $this->get_setting('exclude', '');
            if (!empty($excluded)) {
                $this->exclusions = array_filter(
                    array_map('trim', explode("\n", $excluded))
                );
            }
        }
        
        // Always exclude certain classes
        $always_exclude = [
            'no-lazy',
            'skip-lazy',
            'prorank-no-lazy',
            'rev-slidebg', // Revolution Slider
            'rsImg', // RoyalSlider
        ];
        
        foreach ($always_exclude as $exclude) {
            if (strpos($value, $exclude) !== false) {
                return true;
            }
        }
        
        // Check user-defined exclusions
        foreach ($this->exclusions as $pattern) {
            if (strpos($value, $pattern) !== false) {
                return true;
            }
        }
        
        return false;
    }

    /**
     * Check if the current page request should be excluded.
     *
     * @return bool
     */
    private function is_current_request_excluded(): bool {
        $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '';
        if ($request_uri === '') {
            return false;
        }

        $request_url = home_url($request_uri);
        $request_path = (string) wp_parse_url($request_url, PHP_URL_PATH);

        return $this->is_excluded($request_uri)
            || ($request_path !== '' && $this->is_excluded($request_path))
            || $this->is_excluded($request_url);
    }

    /**
     * Determine if lazy features need full-page output buffering.
     *
     * Images already use direct attribute/content hooks; builder iframes and
     * background/video markup need the final HTML pass.
     *
     * @return bool
     */
    private function should_process_full_page_buffer(): bool {
        return (bool) $this->get_setting('iframes', false)
            || (bool) $this->get_setting('videos', false)
            || (bool) $this->get_setting('background_images', false);
    }

    /**
     * Restrict full-page lazy processing to real frontend renders.
     *
     * @return bool
     */
    private function is_frontend_output_context(): bool {
        if (is_admin() || is_feed() || wp_doing_ajax() || wp_doing_cron()) {
            return false;
        }

        if ((defined('REST_REQUEST') && REST_REQUEST) || (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST)) {
            return false;
        }

        if (defined('WP_CLI') && WP_CLI) {
            return false;
        }

        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return false;
        }

        if ($this->is_amp() || $this->is_current_request_excluded()) {
            return false;
        }

        return true;
    }

    /**
     * Inject runtime script markup into a processed full-page buffer.
     *
     * @param string $buffer
     * @return string
     */
    private function inject_lazy_load_script_markup(string $buffer): string {
        if (!$this->js_added || strpos($buffer, 'id="prorank-lazy-load"') !== false) {
            return $buffer;
        }

        ob_start();
        $this->add_lazy_load_script();
        $script = (string) ob_get_clean();

        if ($script === '') {
            return $buffer;
        }

        if (stripos($buffer, '</body>') !== false) {
            return (string) preg_replace('/<\/body>/i', $script . '</body>', $buffer, 1);
        }

        return $buffer . $script;
    }

    /**
     * Resolve the observer margin from the saved threshold setting.
     *
     * @return string
     */
    private function get_observer_root_margin(): string {
        $threshold = (int) $this->get_setting('threshold', 200);
        $threshold = max(0, min(1000, $threshold));

        return $threshold . 'px 0px';
    }
    
    /**
     * Check if current request is AMP
     *
     * @return bool
     */
    private function is_amp(): bool {
        return function_exists('amp_is_request') && amp_is_request();
    }
}
