<?php

/**
 * HTML Minification Module
 *
 * Minifies HTML output by removing unnecessary whitespace, comments,
 * and optional attributes. Hooks into the centralized output buffer
 * at priority 35 as documented in Buffer.php.
 *
 * @package ProRank\SEO\Modules\Performance
 * @since   2.1.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Modules\Performance;

use ProRank\SEO\Core\Buffer;
use ProRank\SEO\Modules\BaseModule;

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


/**
 * HtmlMinifyModule class
 *
 * Strips whitespace, comments, and redundant attributes from HTML output.
 */


class HtmlMinifyModule extends BaseModule {

    /**
     * Preserved content blocks (replaced with placeholders during minification)
     *
     * @var array<string, string>
     */
    private array $preserved = [];

    /**
     * Placeholder counter
     *
     * @var int
     */
    private int $placeholder_index = 0;

    /**
     * Constructor
     */
    public function __construct() {
        $this->slug = 'html_minify';
        $this->name = 'HTML Minification';
        $this->description = 'Minify HTML output to reduce page size';
        $this->feature_tier = 'free';
        $this->parent_slug = 'performance';
    }

    /**
     * Initialize module hooks
     *
     * @return void
     */
    public function init_hooks(): void {
        if ( ! $this->should_run() ) {
            return;
        }

        // Ensure centralized output buffering is active.
        Buffer::init();

        // Hook into the output buffer at priority 35 (reserved for HTML minification)
        add_filter( 'prorank_output_buffer', [ $this, 'minify_html' ], 35 );
    }

    /**
     * Check if module should run
     *
     * @return bool
     */
    private function should_run(): bool {
        if ( ! $this->is_enabled() ) {
            return false;
        }

        if ( ! $this->get_setting( 'minify_html', false ) ) {
            return false;
        }

        // Don't minify for logged-in admins in debug mode
        if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
            if ( defined( 'PRORANK_DEBUG' ) && PRORANK_DEBUG ) {
                return false;
            }
        }

        return true;
    }

    /**
     * Minify HTML output
     *
     * Strategy: preserve content that must not be touched (pre, script, style,
     * textarea, code, svg), then collapse whitespace and remove comments in the
     * remaining HTML, then restore preserved blocks.
     *
     * @param string $html Full page HTML
     * @return string Minified HTML
     */
    public function minify_html( string $html ): string {
        if ( empty( $html ) ) {
            return $html;
        }

        $original_size = strlen( $html );

        // Reset state
        $this->preserved = [];
        $this->placeholder_index = 0;

        // 0. Strip known no-op third-party tracker payloads before any
        // subsequent HTML cleanup. This is intentionally conservative and only
        // removes providers when their own inline config shows they have no
        // active events/pixels to run.
        $html = $this->strip_noop_third_party_trackers( $html );

        // 1. Normalize and deduplicate resource hints in the head.
        // This prevents excessive preconnects and duplicate preloads that
        // waste bandwidth and can regress mobile performance.
        $html = $this->optimize_link_hints( $html );

        // 2. Preserve blocks that must not be modified
        $html = $this->preserve_blocks( $html );

        // 3. Remove HTML comments (except conditional IE comments and WP markers)
        if ( $this->get_setting( 'remove_html_comments', true ) ) {
            $html = $this->remove_comments( $html );
        }

        // 4. Collapse whitespace between tags
        // Multiple whitespace characters between tags → single space
        $html = preg_replace( '/>\s{2,}</', '> <', $html );

        // Remove whitespace around block-level tags (safe to collapse completely)
        $block_tags = 'html|head|body|div|section|article|aside|header|footer|nav|main|p|ul|ol|li|table|tr|td|th|thead|tbody|tfoot|h[1-6]|form|fieldset|details|summary|figure|figcaption|picture|source|link|meta|title|br|hr';
        $html = preg_replace( '/\s*(<\/?(?:' . $block_tags . ')[\s>])/i', '$1', $html );

        // 5. Remove optional closing tags if setting enabled
        // (Conservative — only safe ones like </li>, </td>, </tr>, </th>, </option>)

        // 6. Remove unnecessary whitespace in tag attributes
        // Multiple spaces in attributes → single space
        $html = preg_replace( '/(<[a-z][a-z0-9]*)\s{2,}/i', '$1 ', $html );

        // 7. Remove optional attribute quotes for simple values (disabled by default — can break some parsers)

        // 8. Restore preserved blocks
        $html = $this->restore_blocks( $html );

        // Only return minified version if it actually saved space
        $minified_size = strlen( $html );
        if ( $minified_size >= $original_size ) {
            return $html;
        }

        return $html;
    }

    /**
     * Remove third-party tracker assets that are provably no-op on this page.
     *
     * Current heuristic is intentionally narrow:
     * - only PixelYourSite
     * - only when its inline config advertises no pixels/events configured
     * - only when the page also includes its own "no pixel configured" warning
     *
     * @param string $html
     * @return string
     */
    private function strip_noop_third_party_trackers( string $html ): string {
        if ( stripos( $html, 'pixelyoursite' ) === false && stripos( $html, 'pysOptions' ) === false ) {
            return $html;
        }

        if ( ! $this->should_strip_noop_pixelyoursite( $html ) ) {
            return $html;
        }

        $patterns = [
            '/<script\b[^>]*id=(["\'])pys-config-warning-script\1[^>]*>.*?<\/script>\s*/is',
            '/<script\b[^>]*id=(["\'])pys-js-extra\1[^>]*>.*?<\/script>\s*/is',
            '/<script\b[^>]*src=(["\'])[^"\']*\/pixelyoursite\/dist\/scripts\/[^"\']+\1[^>]*>\s*<\/script>\s*/is',
            '/<script\b[^>]*src=(["\'])[^"\']*\/pixelyoursite\/dist\/scripts\/[^"\']+\1[^>]*\/>\s*/is',
        ];

        foreach ( $patterns as $pattern ) {
            $html = preg_replace( $pattern, '', $html ) ?? $html;
        }

        return $html;
    }

    /**
     * Determine whether PixelYourSite is present but effectively unconfigured.
     *
     * @param string $html
     * @return bool
     */
    private function should_strip_noop_pixelyoursite( string $html ): bool {
        if ( stripos( $html, "PixelYourSite: no pixel configured." ) === false ) {
            return false;
        }

        if ( preg_match( '/var\s+pysOptions\s*=\s*(\{.*?\});/is', $html, $match ) !== 1 ) {
            return false;
        }

        $config = trim( (string) ( $match[1] ?? '' ) );
        if ( $config === '' ) {
            return false;
        }

        foreach ( [ '"staticEvents":[]', '"dynamicEvents":[]', '"triggerEvents":[]' ] as $needle ) {
            if ( strpos( $config, $needle ) === false ) {
                return false;
            }
        }

        $provider_enabled_patterns = [
            '/"facebook"\s*:\s*\{[^{}]*"enabled"\s*:\s*true/is',
            '/"google"\s*:\s*\{[^{}]*"enabled"\s*:\s*true/is',
            '/"ga"\s*:\s*\{[^{}]*"enabled"\s*:\s*true/is',
            '/"google_ads"\s*:\s*\{[^{}]*"enabled"\s*:\s*true/is',
            '/"pinterest"\s*:\s*\{[^{}]*"enabled"\s*:\s*true/is',
            '/"bing"\s*:\s*\{[^{}]*"enabled"\s*:\s*true/is',
            '/"reddit"\s*:\s*\{[^{}]*"enabled"\s*:\s*true/is',
            '/"tiktok"\s*:\s*\{[^{}]*"enabled"\s*:\s*true/is',
        ];

        foreach ( $provider_enabled_patterns as $pattern ) {
            if ( preg_match( $pattern, $config ) === 1 ) {
                return false;
            }
        }

        return true;
    }

    /**
     * Optimize link hints globally.
     *
     * - Deduplicate preconnects by host.
     * - Cap preconnect hints to a small budget (default: 4).
     * - Deduplicate identical preload hints (same href + as + crossorigin).
     *
     * @param string $html HTML content
     * @return string
     */
    private function optimize_link_hints( string $html ): string {
        if ( stripos( $html, '<link' ) === false ) {
            return $html;
        }

        $max_preconnect = (int) apply_filters( 'prorank_html_max_preconnect_hints', 4 );
        if ( $max_preconnect < 0 ) {
            $max_preconnect = 0;
        }

        $seen_preconnect_hosts = [];
        $kept_preconnect = 0;
        $seen_preloads = [];

        return (string) preg_replace_callback(
            '/<link\b[^>]*>/i',
            function ( array $matches ) use ( &$seen_preconnect_hosts, &$kept_preconnect, $max_preconnect, &$seen_preloads ): string {
                $tag = $matches[0];
                $tag_l = strtolower( $tag );

                if ( strpos( $tag_l, ' rel=' ) === false || strpos( $tag_l, ' href=' ) === false ) {
                    return $tag;
                }

                $rel = $this->extract_link_attr( $tag, 'rel' );
                $href = $this->extract_link_attr( $tag, 'href' );
                if ( $rel === '' || $href === '' ) {
                    return $tag;
                }

                $rel_tokens = preg_split( '/\s+/', strtolower( trim( $rel ) ) ) ?: [];
                $has_preconnect = in_array( 'preconnect', $rel_tokens, true );
                $has_preload = in_array( 'preload', $rel_tokens, true );

                if ( $has_preconnect ) {
                    $host = strtolower( (string) wp_parse_url( html_entity_decode( $href, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), PHP_URL_HOST ) );
                    if ( $host === '' ) {
                        return $tag;
                    }

                    if ( isset( $seen_preconnect_hosts[ $host ] ) ) {
                        return '';
                    }

                    if ( $max_preconnect > 0 && $kept_preconnect >= $max_preconnect ) {
                        return '';
                    }

                    $seen_preconnect_hosts[ $host ] = true;
                    $kept_preconnect++;
                    return $tag;
                }

                if ( $has_preload ) {
                    $as = strtolower( trim( $this->extract_link_attr( $tag, 'as' ) ) );
                    $crossorigin = strtolower( trim( $this->extract_link_attr( $tag, 'crossorigin' ) ) );
                    $key = strtolower( trim( html_entity_decode( $href, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) ) . '|' . $as . '|' . $crossorigin;

                    if ( $key === '||' ) {
                        return $tag;
                    }

                    if ( isset( $seen_preloads[ $key ] ) ) {
                        return '';
                    }

                    $seen_preloads[ $key ] = true;
                    return $tag;
                }

                return $tag;
            },
            $html
        );
    }

    /**
     * Extract an HTML attribute value from a <link> tag.
     *
     * @param string $tag
     * @param string $attr
     * @return string
     */
    private function extract_link_attr( string $tag, string $attr ): string {
        $pattern = '/\b' . preg_quote( $attr, '/' ) . '\s*=\s*([\'"])(.*?)\1/i';
        if ( preg_match( $pattern, $tag, $m ) !== 1 ) {
            return '';
        }
        return (string) ( $m[2] ?? '' );
    }

    /**
     * Preserve content blocks that should not be minified
     *
     * @param string $html HTML content
     * @return string HTML with placeholders
     */
    private function preserve_blocks( string $html ): string {
        // Preserve <pre>, <script>, <style>, <textarea>, <code>, <svg> blocks
        $patterns = [
            '/<pre\b[^>]*>.*?<\/pre>/is',
            '/<script\b[^>]*>.*?<\/script>/is',
            '/<style\b[^>]*>.*?<\/style>/is',
            '/<textarea\b[^>]*>.*?<\/textarea>/is',
            '/<code\b[^>]*>.*?<\/code>/is',
            '/<svg\b[^>]*>.*?<\/svg>/is',
        ];

        foreach ( $patterns as $pattern ) {
            $html = preg_replace_callback( $pattern, function ( array $matches ): string {
                $placeholder = '<!--PRORANK_PRESERVE_' . $this->placeholder_index . '-->';
                $this->preserved[ $placeholder ] = $matches[0];
                $this->placeholder_index++;
                return $placeholder;
            }, $html );
        }

        return $html;
    }

    /**
     * Restore preserved content blocks
     *
     * @param string $html HTML with placeholders
     * @return string HTML with restored blocks
     */
    private function restore_blocks( string $html ): string {
        return str_replace(
            array_keys( $this->preserved ),
            array_values( $this->preserved ),
            $html
        );
    }

    /**
     * Remove HTML comments (preserving conditional and WordPress markers)
     *
     * @param string $html HTML content
     * @return string HTML without comments
     */
    private function remove_comments( string $html ): string {
        return preg_replace_callback(
            '/<!--(.*?)-->/s',
            function ( array $matches ): string {
                $comment = $matches[1];

                // Keep preserved block placeholders
                if ( strpos( $comment, 'PRORANK_PRESERVE_' ) === 0 ) {
                    return $matches[0];
                }

                // Keep IE conditional comments
                if ( preg_match( '/^\[if\s/', $comment ) || $comment === '<![endif]' ) {
                    return $matches[0];
                }

                // Keep WordPress insert_with_markers comments
                if ( preg_match( '/^#?\s*(BEGIN|END)\s/', trim( $comment ) ) ) {
                    return $matches[0];
                }

                // Keep noindex/nofollow directives
                if ( preg_match( '/googleoff|googleon|noindex|nofollow/i', $comment ) ) {
                    return $matches[0];
                }

                // Remove everything else
                return '';
            },
            $html
        );
    }
}
