<?php
/**
 * Rank Booster Render Probe
 *
 * Fetches the rendered frontend of a URL and measures how much of a given
 * block of text is actually visible on the page (the "coverage" score).
 *
 * Two jobs:
 *  1. Safety net — after an optimization we can check whether the content we
 *     just rewrote actually appears on the live page. If coverage is low the
 *     theme is probably rendering from a builder / custom field we don't reach,
 *     so we emit an honest warning instead of pretending the change is live.
 *  2. Field selection — the Custom Fields provider uses the rendered HTML to
 *     decide which ACF/postmeta fields are part of the visible main content.
 *
 * Deliberately dependency-free: plain wp_remote_get + DOMDocument.
 */

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

class Rank_Booster_Render_Probe {

    /** Coverage at/above this is considered "visible". */
    const COVERAGE_OK = 0.6;

    /** In-request cache of fetched main text, keyed by URL. */
    private static $cache = array();

    /**
     * Fetch a URL's rendered HTML with cache-busting, returning the body.
     *
     * @param string $url
     * @return string|WP_Error
     */
    public static function fetch( $url ) {
        if ( empty( $url ) ) {
            return new WP_Error( 'no_url', 'No URL to probe.' );
        }

        // Cache-bust to defeat full-page / CDN caches.
        $bust = add_query_arg( '_rb_cb', (string) time(), $url );

        $resp = wp_remote_get( $bust, array(
            'timeout'     => 15,
            'redirection' => 3,
            'headers'     => array(
                'Cache-Control' => 'no-cache, no-store, must-revalidate',
                'Pragma'        => 'no-cache',
            ),
            // Identify ourselves; some caches vary on this.
            'user-agent'  => 'MorningscoreRankBooster/1.0 (+render-probe)',
        ) );

        if ( is_wp_error( $resp ) ) {
            return $resp;
        }
        $code = wp_remote_retrieve_response_code( $resp );
        if ( $code < 200 || $code >= 400 ) {
            return new WP_Error( 'bad_status', 'Probe HTTP ' . $code );
        }
        return (string) wp_remote_retrieve_body( $resp );
    }

    /**
     * Extract normalized visible main-content text from rendered HTML.
     * Strips scripts/styles and page chrome (header/footer/nav/aside and
     * common banner/nav/sidebar roles & classes).
     *
     * @param string $html
     * @return string  Normalized lowercase plaintext.
     */
    public static function extract_main_text( $html ) {
        if ( ! is_string( $html ) || $html === '' ) {
            return '';
        }

        $dom = new DOMDocument();
        libxml_use_internal_errors( true );
        $dom->loadHTML(
            '<?xml encoding="utf-8" ?>' . $html,
            LIBXML_NOWARNING | LIBXML_NOERROR
        );
        libxml_clear_errors();

        $xpath = new DOMXPath( $dom );

        // Remove non-content + chrome nodes.
        $remove = $xpath->query(
            '//script | //style | //noscript | //template | //svg'
            . ' | //header | //footer | //nav | //aside'
            . ' | //*[@role="banner" or @role="navigation" or @role="contentinfo" or @role="complementary"]'
            . ' | //*[contains(concat(" ", normalize-space(@class), " "), " site-header ")]'
            . ' | //*[contains(concat(" ", normalize-space(@class), " "), " site-footer ")]'
            . ' | //*[contains(concat(" ", normalize-space(@class), " "), " menu ")]'
            . ' | //*[contains(concat(" ", normalize-space(@class), " "), " sidebar ")]'
            . ' | //*[contains(concat(" ", normalize-space(@class), " "), " widget ")]'
        );
        if ( $remove ) {
            // Iterate over a static list — removing while querying is unsafe.
            $nodes = array();
            foreach ( $remove as $n ) {
                $nodes[] = $n;
            }
            foreach ( $nodes as $n ) {
                if ( $n->parentNode ) {
                    $n->parentNode->removeChild( $n );
                }
            }
        }

        $body = $dom->getElementsByTagName( 'body' )->item( 0 );
        $text = $body ? $body->textContent : $dom->textContent;

        return self::normalize( $text );
    }

    /**
     * Coverage of $needle_html's text within rendered $url, 0..1.
     * Result is cached per URL for the request.
     *
     * @param string $url
     * @param string $needle_html  HTML/text whose visibility we test.
     * @return array  [ 'score'=>float, 'covered'=>int, 'total'=>int, 'error'=>string|null ]
     */
    public static function coverage( $url, $needle_html ) {
        if ( ! isset( self::$cache[ $url ] ) ) {
            $html = self::fetch( $url );
            if ( is_wp_error( $html ) ) {
                self::$cache[ $url ] = $html; // cache the error too
            } else {
                self::$cache[ $url ] = self::extract_main_text( $html );
            }
        }

        $rendered = self::$cache[ $url ];
        if ( is_wp_error( $rendered ) ) {
            return array( 'score' => 0.0, 'covered' => 0, 'total' => 0, 'error' => $rendered->get_error_message() );
        }

        return self::score_against( $rendered, $needle_html );
    }

    /**
     * Score how many prose snippets of $needle_html appear in $rendered text.
     *
     * @param string $rendered  Normalized rendered main text.
     * @param string $needle_html
     * @return array
     */
    public static function score_against( $rendered, $needle_html ) {
        $snippets = self::snippets( $needle_html );
        $total    = count( $snippets );
        if ( $total === 0 ) {
            return array( 'score' => 0.0, 'covered' => 0, 'total' => 0, 'error' => null );
        }

        $covered = 0;
        foreach ( $snippets as $s ) {
            if ( $s !== '' && strpos( $rendered, $s ) !== false ) {
                $covered++;
            }
        }
        return array(
            'score'   => $total ? $covered / $total : 0.0,
            'covered' => $covered,
            'total'   => $total,
            'error'   => null,
        );
    }

    /**
     * Break text into normalized comparable snippets (sentence-ish chunks).
     *
     * @param string $html
     * @return string[]
     */
    public static function snippets( $html ) {
        $text = self::normalize( wp_strip_all_tags( (string) $html ) );
        if ( $text === '' ) {
            return array();
        }
        // Split on sentence boundaries / list breaks; keep meaningful chunks.
        $parts = preg_split( '/(?<=[.!?])\s+|\s*[\r\n]+\s*/', $text );
        $out   = array();
        foreach ( $parts as $p ) {
            $p = trim( $p );
            // Only count chunks with enough words to be a meaningful match.
            if ( strlen( $p ) >= 25 && substr_count( $p, ' ' ) >= 3 ) {
                $out[] = $p;
            }
        }
        // Fallback: if nothing chunked, use the whole normalized string.
        if ( empty( $out ) && strlen( $text ) >= 25 ) {
            $out[] = $text;
        }
        return $out;
    }

    /**
     * Normalize text for robust matching: lowercase, collapse whitespace,
     * strip zero-width chars. (Intentionally keeps letters/numbers/punctuation
     * so snippets remain specific.)
     *
     * @param string $text
     * @return string
     */
    public static function normalize( $text ) {
        $text = (string) $text;
        $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
        $text = str_replace( array( "\xEF\xBB\xBF", "\xC2\xA0" ), ' ', $text ); // BOM, nbsp
        $text = preg_replace( '/\s+/u', ' ', $text );
        $text = function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text );
        return trim( $text );
    }
}
