<?php

/**
 * Helper functions for Content Performance Analyzer
 *
 * @package ContentPerformanceAnalyzer
 * @since 1.0.0
 */
// Prevent direct access
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * Return tooltip text for a given flag type.
 */
if ( !function_exists( 'cpanalyzer_get_flag_tooltip' ) ) {
    function cpanalyzer_get_flag_tooltip(  $flag  ) {
        $tooltips = array(
            'decay'      => __( 'Traffic has declined significantly over the selected period.', 'content-performance-analyzer' ),
            'seo'        => __( 'SEO opportunities detected for this page.', 'content-performance-analyzer' ),
            'conversion' => __( 'Conversion issues detected — may need CRO review.', 'content-performance-analyzer' ),
        );
        return $tooltips[$flag] ?? __( 'No tooltip available.', 'content-performance-analyzer' );
    }

}
/**
 * Return a color/arrow indicator based on % change.
 */
if ( !function_exists( 'cpanalyzer_get_change_indicator' ) ) {
    function cpanalyzer_get_change_indicator(  $change  ) {
        if ( !is_numeric( $change ) ) {
            return array(
                'color' => '#000',
                'arrow' => '',
            );
        }
        if ( $change > 0 ) {
            return array(
                'color' => '#27ae60',
                'arrow' => '▲',
            );
        } elseif ( $change < 0 ) {
            return array(
                'color' => '#e74c3c',
                'arrow' => '▼',
            );
        } else {
            return array(
                'color' => '#999',
                'arrow' => '–',
            );
        }
    }

}
/**
 * Normalize URL path for consistent comparison.
 */
if ( !function_exists( 'cpanalyzer_normalize_path' ) ) {
    function cpanalyzer_normalize_path(  $path  ) {
        $path = trim( strtolower( $path ) );
        $path = ( wp_parse_url( $path, PHP_URL_PATH ) ?: '/' );
        // Treat home and homepage aliases as '/'
        if ( in_array( $path, [
            '',
            '/',
            '/index.php',
            '/index.html'
        ], true ) ) {
            $path = '/';
        }
        // Remove trailing slash (unless it's just '/')
        if ( $path !== '/' && substr( $path, -1 ) === '/' ) {
            $path = rtrim( $path, '/' );
        }
        // Remove index.php or index.html at the end
        $path = preg_replace( '#/(index\\.php|index\\.html)$#', '', $path );
        // Collapse double slashes
        $path = preg_replace( '#/+#', '/', $path );
        return ( $path ?: '/' );
    }

}
/**
 * Whether the selected AI provider has everything it needs to generate.
 *
 * The single gate for every "can we produce recommendations?" decision.
 * Reading cpanalyzer_openai_key directly is wrong now that a provider
 * exists which carries no key of its own — the hosted relay authenticates
 * with an installation token plus a Pro license instead.
 */
if ( !function_exists( 'cpanalyzer_ai_is_configured' ) ) {
    function cpanalyzer_ai_is_configured() {
        if ( !class_exists( 'CPAP_AI_Processor' ) ) {
            return '' !== trim( (string) get_option( 'cpanalyzer_openai_key', '' ) );
        }
        $processor = new CPAP_AI_Processor();
        return $processor->is_configured();
    }

}
/**
 * Check if AI suggestion is expired.
 */
if ( !function_exists( 'cpanalyzer_is_suggestion_expired' ) ) {
    function cpanalyzer_is_suggestion_expired(  $timestamp  ) {
        return time() - $timestamp > 7 * 24 * 60 * 60;
        // 7 days
    }

}
/**
 * Check if we should refresh AI suggestion for a page.
 */
if ( !function_exists( 'cpanalyzer_should_refresh_suggestion' ) ) {
    function cpanalyzer_should_refresh_suggestion(  $url  ) {
        $actions = get_option( 'cpanalyzer_page_actions', array() );
        foreach ( $actions as $action ) {
            if ( isset( $action['url'] ) && $action['url'] === $url ) {
                $timestamp = $action['timestamp'] ?? 0;
                return cpanalyzer_is_suggestion_expired( $timestamp );
            }
        }
        return true;
        // No existing suggestion, so refresh
    }

}
/**
 * Format number with appropriate suffix.
 */
if ( !function_exists( 'cpanalyzer_format_number' ) ) {
    function cpanalyzer_format_number(  $number  ) {
        if ( $number === '' || $number === null || $number === false || !is_numeric( $number ) ) {
            return '0';
        }
        $number = floatval( $number );
        if ( $number < 0 ) {
            return '-' . cpanalyzer_format_number( abs( $number ) );
        }
        if ( $number >= 1000000 ) {
            return number_format( $number / 1000000, 1 ) . 'M';
        } elseif ( $number >= 1000 ) {
            return number_format( $number / 1000, 1 ) . 'K';
        } else {
            return number_format( $number, 0 );
        }
    }

}
/**
 * Sanitize Search Console site URL.
 */
if ( !function_exists( 'cpanalyzer_sanitize_site_url' ) ) {
    function cpanalyzer_sanitize_site_url(  $url  ) {
        $url = trim( $url );
        if ( strpos( $url, 'sc-domain:' ) === 0 ) {
            $domain = substr( $url, 10 );
            $domain = sanitize_text_field( $domain );
            return 'sc-domain:' . $domain;
        }
        return esc_url_raw( $url );
    }

}
/**
 * Select the best primary keyword for a URL.
 *
 * Priority: GSC direct query (best by clicks desc, impressions desc) → AI-derived
 * keyword validated for relevance → empty/default.
 *
 * @param array  $gsc_keywords  GSC keyword rows for the URL (each with 'keyword', 'clicks', 'impressions', 'position').
 * @param string $ai_keyword    AI-derived fallback keyword (may be empty).
 * @param array  $page_context  Associative array with optional keys: 'title', 'url', 'headings' (array of strings).
 * @return array {keyword: string, source: string, position: float}
 */
if ( !function_exists( 'cpanalyzer_get_keyword_row_text' ) ) {
    function cpanalyzer_get_keyword_row_text(  $keyword_row  ) {
        if ( is_string( $keyword_row ) ) {
            return sanitize_text_field( trim( $keyword_row ) );
        }
        if ( !is_array( $keyword_row ) ) {
            return '';
        }
        $keyword = $keyword_row['keyword'] ?? $keyword_row['query'] ?? '';
        return sanitize_text_field( trim( (string) $keyword ) );
    }

}
if ( !function_exists( 'cpanalyzer_normalize_gsc_keywords' ) ) {
    function cpanalyzer_normalize_gsc_keywords(  $gsc_keywords = array()  ) {
        if ( !is_array( $gsc_keywords ) ) {
            return array();
        }
        $normalized = array();
        $seen = array();
        foreach ( $gsc_keywords as $keyword_row ) {
            $keyword = cpanalyzer_get_keyword_row_text( $keyword_row );
            if ( $keyword === '' ) {
                continue;
            }
            $keyword_meta = ( is_array( $keyword_row ) ? $keyword_row : array() );
            $normalized_row = array(
                'keyword'     => $keyword,
                'clicks'      => (int) ($keyword_meta['clicks'] ?? 0),
                'impressions' => (int) ($keyword_meta['impressions'] ?? 0),
                'position'    => (float) ($keyword_meta['position'] ?? 0),
            );
            $normalized_key = strtolower( $keyword );
            if ( !isset( $seen[$normalized_key] ) ) {
                $seen[$normalized_key] = count( $normalized );
                $normalized[] = $normalized_row;
                continue;
            }
            $existing_index = $seen[$normalized_key];
            $existing_row = $normalized[$existing_index];
            $replace_row = $normalized_row['clicks'] > $existing_row['clicks'] || $normalized_row['clicks'] === $existing_row['clicks'] && $normalized_row['impressions'] > $existing_row['impressions'];
            if ( $replace_row ) {
                $normalized[$existing_index] = $normalized_row;
            }
        }
        usort( $normalized, static function ( $a, $b ) {
            $clicks_diff = (int) ($b['clicks'] ?? 0) - (int) ($a['clicks'] ?? 0);
            if ( $clicks_diff !== 0 ) {
                return $clicks_diff;
            }
            return (int) ($b['impressions'] ?? 0) - (int) ($a['impressions'] ?? 0);
        } );
        return $normalized;
    }

}
if ( !function_exists( 'cpanalyzer_extract_page_context_fragments' ) ) {
    function cpanalyzer_extract_page_context_fragments(  $page_context = array()  ) {
        if ( !is_array( $page_context ) || empty( $page_context ) ) {
            return array();
        }
        $fragments = array();
        if ( !empty( $page_context['title'] ) && is_string( $page_context['title'] ) ) {
            $fragments[] = strtolower( $page_context['title'] );
        }
        if ( !empty( $page_context['url'] ) && is_string( $page_context['url'] ) ) {
            $path = wp_parse_url( $page_context['url'], PHP_URL_PATH );
            if ( $path ) {
                $fragments[] = strtolower( str_replace( array(
                    '/',
                    '-',
                    '_',
                    '.'
                ), ' ', $path ) );
            }
        }
        $headings = $page_context['headings'] ?? array();
        if ( is_array( $headings ) ) {
            foreach ( $headings as $heading ) {
                if ( is_string( $heading ) ) {
                    $text = trim( $heading );
                } elseif ( is_array( $heading ) ) {
                    $text = trim( (string) ($heading['text'] ?? '') );
                } elseif ( is_object( $heading ) ) {
                    $text = trim( (string) ($heading->text ?? '') );
                } else {
                    $text = '';
                }
                if ( $text !== '' ) {
                    $fragments[] = strtolower( $text );
                }
            }
        }
        return array_values( array_filter( array_unique( $fragments ) ) );
    }

}
if ( !function_exists( 'cpanalyzer_select_primary_keyword' ) ) {
    function cpanalyzer_select_primary_keyword(  $gsc_keywords = array(), $ai_keyword = '', $page_context = array()  ) {
        $empty_result = array(
            'keyword'  => '',
            'source'   => 'None',
            'position' => 0,
        );
        // Try GSC direct keywords first (sorted: clicks desc, then impressions desc).
        $normalized_gsc_keywords = cpanalyzer_normalize_gsc_keywords( $gsc_keywords );
        if ( !empty( $normalized_gsc_keywords ) ) {
            foreach ( $normalized_gsc_keywords as $kw ) {
                if ( !empty( $kw['keyword'] ) ) {
                    return array(
                        'keyword'  => $kw['keyword'],
                        'source'   => 'GSC',
                        'position' => (float) ($kw['position'] ?? 0),
                    );
                }
            }
        }
        // Fall back to AI-derived keyword with relevance guard.
        $ai_keyword = ( is_string( $ai_keyword ) ? trim( $ai_keyword ) : '' );
        if ( $ai_keyword !== '' ) {
            if ( cpanalyzer_validate_keyword_relevance( $ai_keyword, $page_context ) ) {
                return array(
                    'keyword'  => $ai_keyword,
                    'source'   => 'Derived',
                    'position' => 0,
                );
            }
        }
        return $empty_result;
    }

}
/**
 * Validate that a derived keyword is semantically relevant to its page context.
 *
 * Checks whether at least one meaningful word in the keyword appears in the
 * page title, URL slug, or heading text.  Returns true when no context is
 * available (cannot disprove relevance), or when overlap is found.
 *
 * @param string $keyword      The derived keyword to validate.
 * @param array  $page_context Associative array with optional keys: 'title', 'url', 'headings'.
 * @return bool
 */
if ( !function_exists( 'cpanalyzer_validate_keyword_relevance' ) ) {
    function cpanalyzer_validate_keyword_relevance(  $keyword, $page_context = array()  ) {
        $keyword = strtolower( trim( (string) $keyword ) );
        if ( $keyword === '' ) {
            return false;
        }
        // Build a single haystack from available page signals.
        $haystack_parts = cpanalyzer_extract_page_context_fragments( $page_context );
        // If we have no usable page signals, fail closed instead of showing a misleading fallback.
        if ( empty( $haystack_parts ) ) {
            return false;
        }
        $haystack = implode( ' ', $haystack_parts );
        // Check if the full keyword phrase appears in the haystack.
        if ( strpos( $haystack, $keyword ) !== false ) {
            return true;
        }
        // Tokenize keyword and check for meaningful word overlap.
        // Filter out very short stopword-like tokens (<=2 chars).
        $keyword_tokens = preg_split(
            '/[\\s\\-_]+/',
            $keyword,
            -1,
            PREG_SPLIT_NO_EMPTY
        );
        $meaningful_tokens = array_filter( $keyword_tokens, static function ( $token ) {
            return strlen( $token ) > 2;
        } );
        if ( empty( $meaningful_tokens ) ) {
            // Keyword is only short tokens (e.g. "TCP") — check exact presence.
            foreach ( $keyword_tokens as $token ) {
                // For short tokens use word-boundary matching to avoid false substring matches.
                if ( preg_match( '/\\b' . preg_quote( $token, '/' ) . '\\b/i', $haystack ) ) {
                    return true;
                }
            }
            return false;
        }
        // Require at least half of the meaningful tokens to appear in context.
        $matches = 0;
        foreach ( $meaningful_tokens as $token ) {
            if ( strpos( $haystack, $token ) !== false ) {
                $matches++;
            }
        }
        $threshold = max( 1, min( count( $meaningful_tokens ), (int) ceil( count( $meaningful_tokens ) * 0.6 ) ) );
        return $matches >= $threshold;
    }

}
if ( !function_exists( 'cpanalyzer_render_setup_mode_notice' ) ) {
    /**
     * Show an informational notice on plugin pages when Google and/or an AI
     * key are not configured yet, so a fresh install understands it is
     * running in content-only mode instead of assuming the plugin is broken.
     *
     * Deliberately silent when a previously working Google connection has
     * failed — that state gets its own error banner via
     * CPAP_Admin::render_google_connection_notice().
     */
    function cpanalyzer_render_setup_mode_notice() {
        if ( !current_user_can( 'manage_options' ) ) {
            return;
        }
        $google_connected = ( class_exists( 'CPAP_Google_OAuth' ) ? ( new CPAP_Google_OAuth() )->is_connected() : false );
        $ai_key_set = cpanalyzer_ai_is_configured();
        if ( $google_connected && $ai_key_set ) {
            return;
        }
        $settings_url = admin_url( 'admin.php?page=cpanalyzer-settings' );
        // A site running the hosted provider has no key to add, so the
        // key-shaped advice below would send it looking for the wrong thing.
        $ai_hosted = false;
        ?>
        <div class="notice notice-info">
            <?php 
        if ( !$google_connected && !$ai_key_set ) {
            ?>
                <p>
                    <strong><?php 
            esc_html_e( 'Content-only mode', 'content-performance-analyzer' );
            ?></strong>
                    — <?php 
            esc_html_e( 'your pages are checked for content issues like missing alt text, thin content, and heading problems. No setup needed for that.', 'content-performance-analyzer' );
            ?>
                </p>
                <p>
                    <?php 
            if ( $ai_hosted ) {
                ?>
                        <?php 
                esc_html_e( 'To unlock the full analysis: connect Google for traffic data (views, impressions, CTR) and check the license so AI recommendations can run.', 'content-performance-analyzer' );
                ?>
                    <?php 
            } else {
                ?>
                        <?php 
                esc_html_e( 'To unlock the full analysis: connect Google for traffic data (views, impressions, CTR) and add an OpenAI or Groq key for AI recommendations.', 'content-performance-analyzer' );
                ?>
                    <?php 
            }
            ?>
                </p>
            <?php 
        } elseif ( !$google_connected ) {
            ?>
                <p>
                    <strong><?php 
            esc_html_e( 'Google isn\'t connected', 'content-performance-analyzer' );
            ?></strong>
                    — <?php 
            esc_html_e( 'traffic data (views, impressions, CTR, conversions) won\'t appear. Content checks and AI recommendations still run.', 'content-performance-analyzer' );
            ?>
                </p>
            <?php 
        } else {
            ?>
                <p>
                    <strong><?php 
            esc_html_e( 'AI recommendations are off', 'content-performance-analyzer' );
            ?></strong>
                    — <?php 
            if ( $ai_hosted ) {
                ?>
                        <?php 
                esc_html_e( 'check the license in Settings to get page-level fix plans. Traffic data and content checks still run.', 'content-performance-analyzer' );
                ?>
                    <?php 
            } else {
                ?>
                        <?php 
                esc_html_e( 'add an OpenAI or Groq API key to get page-level fix plans. Traffic data and content checks still run.', 'content-performance-analyzer' );
                ?>
                    <?php 
            }
            ?>
                </p>
            <?php 
        }
        ?>
            <p>
                <a href="<?php 
        echo esc_url( $settings_url );
        ?>" class="button button-secondary">
                    <?php 
        esc_html_e( 'Open Settings', 'content-performance-analyzer' );
        ?>
                </a>
            </p>
        </div>
        <?php 
    }

}
if ( !function_exists( 'cpanalyzer_aeo_readiness' ) ) {
    /**
     * Score a page's answer-engine readiness (0-100) from deterministic
     * scraped signals — no AI involved, so the score is reproducible and
     * every failed check maps to a concrete fix.
     *
     * @param  array $content Scraped content data for one page.
     * @return array|null {score: int, checks: array<{label, pass, points}>} or null when no scan data.
     */
    function cpanalyzer_aeo_readiness(  $content  ) {
        if ( !is_array( $content ) || empty( $content ) ) {
            return null;
        }
        $question = ( isset( $content['question_analysis'] ) && is_array( $content['question_analysis'] ) ? $content['question_analysis'] : array(
            'question_headings' => array(),
            'answered_count'    => 0,
        ) );
        $schema_types = array_map( 'strval', (array) ($content['schema_types'] ?? array()) );
        $answer_schema = (bool) array_intersect( array(
            'FAQPage',
            'HowTo',
            'Article',
            'BlogPosting',
            'QAPage'
        ), $schema_types );
        $checks = array(
            array(
                'label'  => __( 'Question-phrased headings (H2/H3) that match how people search', 'content-performance-analyzer' ),
                'pass'   => count( $question['question_headings'] ?? array() ) >= 1,
                'points' => 20,
            ),
            array(
                'label'  => __( 'A concise 20-90 word answer paragraph directly under a question heading', 'content-performance-analyzer' ),
                'pass'   => (int) ($question['answered_count'] ?? 0) >= 1,
                'points' => 20,
            ),
            array(
                'label'   => __( 'FAQ, Article, or HowTo structured data (JSON-LD)', 'content-performance-analyzer' ),
                'pass'    => $answer_schema,
                'points'  => 15,
                'partial' => ( !empty( $content['has_schema'] ) ? 7 : 0 ),
            ),
            array(
                'label'  => __( 'Lists or tables — structured content gets quoted by answer engines', 'content-performance-analyzer' ),
                'pass'   => (int) ($content['list_count'] ?? 0) + (int) ($content['table_count'] ?? 0) >= 1,
                'points' => 10,
            ),
            array(
                'label'  => __( 'A machine-readable published or updated date', 'content-performance-analyzer' ),
                'pass'   => !empty( $content['has_date_signal'] ),
                'points' => 10,
            ),
            array(
                'label'  => __( 'At least 300 words of content', 'content-performance-analyzer' ),
                'pass'   => (int) ($content['word_count'] ?? 0) >= 300,
                'points' => 10,
            ),
            array(
                'label'  => __( 'Exactly one H1', 'content-performance-analyzer' ),
                'pass'   => 1 === count( (array) ($content['h1_tags'] ?? array()) ),
                'points' => 10,
            ),
            array(
                'label'  => __( 'A meta description', 'content-performance-analyzer' ),
                'pass'   => (int) ($content['meta_description']['length'] ?? 0) > 0,
                'points' => 5,
            )
        );
        $score = 0;
        foreach ( $checks as $index => $check ) {
            if ( $check['pass'] ) {
                $score += $check['points'];
            } elseif ( !empty( $check['partial'] ) ) {
                $score += (int) $check['partial'];
            }
            unset($checks[$index]['partial']);
        }
        return array(
            'score'  => min( 100, $score ),
            'checks' => array_values( $checks ),
        );
    }

}
if ( !function_exists( 'cpanalyzer_get_question_gaps' ) ) {
    /**
     * Find question-intent GSC queries this page ranks for (positions 2-20,
     * meaningful impressions) that no heading on the page addresses — each
     * is simultaneously a featured-snippet and answer-engine opportunity.
     *
     * @param  string     $url          Normalized page path.
     * @param  array|null $gsc_rows     Optional pre-loaded GSC rows.
     * @param  array|null $content_data Optional pre-loaded content data.
     * @return array<array{query: string, position: float, impressions: int}>
     */
    function cpanalyzer_get_question_gaps(  $url, $gsc_rows = null, $content_data = null  ) {
        $gsc_rows = ( is_array( $gsc_rows ) ? $gsc_rows : (array) get_option( 'cpanalyzer_gsc_rows', array() ) );
        $content_data = ( is_array( $content_data ) ? $content_data : (array) get_option( 'cpanalyzer_content_data', array() ) );
        $keywords = cpanalyzer_normalize_gsc_keywords( $gsc_rows[$url]['keywords'] ?? array() );
        if ( empty( $keywords ) ) {
            return array();
        }
        $content = ( isset( $content_data[$url] ) && is_array( $content_data[$url] ) ? $content_data[$url] : array() );
        $headings = array_merge( (array) ($content['h1_tags'] ?? array()), (array) ($content['h2_tags'] ?? array()), (array) ($content['h3_tags'] ?? array()) );
        $gaps = array();
        foreach ( $keywords as $row ) {
            $query = (string) $row['keyword'];
            if ( !preg_match( '/^(what|how|why|when|where|which|who|can|could|does|do|is|are|should|will)\\b/i', $query ) ) {
                continue;
            }
            if ( $row['position'] < 2 || $row['position'] > 20 || $row['impressions'] < 10 ) {
                continue;
            }
            // Covered already? Match against headings only — a title match
            // doesn't mean there's a liftable answer block.
            if ( !empty( $headings ) && cpanalyzer_validate_keyword_relevance( $query, array(
                'headings' => $headings,
            ) ) ) {
                continue;
            }
            $gaps[] = array(
                'query'       => $query,
                'position'    => round( (float) $row['position'], 1 ),
                'impressions' => (int) $row['impressions'],
            );
        }
        usort( $gaps, static function ( $a, $b ) {
            return $b['impressions'] - $a['impressions'];
        } );
        return array_slice( $gaps, 0, 5 );
    }

}
if ( !function_exists( 'cpanalyzer_maybe_serve_llms_txt' ) ) {
    /**
     * Serve /llms.txt — a machine-readable site guide for AI crawlers —
     * generated from the pages this plugin has already analyzed. Enabled
     * via the cpanalyzer_llms_txt_enable setting (off by default) and
     * served through parse_request, so no rewrite-rule flush is needed.
     */
    function cpanalyzer_maybe_serve_llms_txt() {
        if ( 'yes' !== get_option( 'cpanalyzer_llms_txt_enable', 'no' ) ) {
            return;
        }
        // A physical llms.txt (or one served by another plugin that ran
        // first) always wins — never shadow an existing file.
        if ( file_exists( ABSPATH . 'llms.txt' ) ) {
            return;
        }
        $request_path = ( isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ) : '' );
        if ( '/llms.txt' !== $request_path ) {
            return;
        }
        $content_data = (array) get_option( 'cpanalyzer_content_data', array() );
        $lines = array();
        $lines[] = '# ' . get_bloginfo( 'name' );
        $tagline = trim( (string) get_bloginfo( 'description' ) );
        if ( '' !== $tagline ) {
            $lines[] = '';
            $lines[] = '> ' . $tagline;
        }
        $lines[] = '';
        $lines[] = '## Pages';
        $lines[] = '';
        $count = 0;
        foreach ( $content_data as $path => $content ) {
            if ( !is_array( $content ) || $count >= 100 ) {
                continue;
            }
            $title = trim( (string) ($content['title']['text'] ?? '') );
            if ( '' === $title ) {
                $title = $path;
            }
            $description = trim( (string) ($content['meta_description']['text'] ?? '') );
            $line = '- [' . str_replace( array('[', ']'), '', $title ) . '](' . home_url( (string) $path ) . ')';
            if ( '' !== $description ) {
                $line .= ': ' . $description;
            }
            $lines[] = $line;
            $count++;
        }
        if ( 0 === $count ) {
            $lines[] = '- [' . str_replace( array('[', ']'), '', get_bloginfo( 'name' ) ) . '](' . home_url( '/' ) . ')';
        }
        header( 'Content-Type: text/plain; charset=utf-8' );
        header( 'Cache-Control: max-age=3600' );
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Plain-text response assembled from sanitized parts.
        echo implode( "\n", $lines ) . "\n";
        exit;
    }

    add_action( 'parse_request', 'cpanalyzer_maybe_serve_llms_txt' );
}
if ( !function_exists( 'cpanalyzer_analyze_click_trend' ) ) {
    /**
     * Summarize a series of GSC click buckets (oldest first) into trend metrics.
     *
     * @param array $buckets Click counts per 28-day period, oldest first.
     * @return array{declining_periods:int,peak:int,latest:int,vs_peak_pct:float,buckets:array}
     */
    function cpanalyzer_analyze_click_trend(  $buckets  ) {
        $buckets = array_values( array_map( 'intval', (array) $buckets ) );
        $result = array(
            'declining_periods' => 0,
            'peak'              => 0,
            'latest'            => 0,
            'vs_peak_pct'       => 0.0,
            'buckets'           => $buckets,
        );
        if ( count( $buckets ) < 2 ) {
            return $result;
        }
        $result['peak'] = max( $buckets );
        $result['latest'] = (int) end( $buckets );
        if ( $result['peak'] > 0 ) {
            $result['vs_peak_pct'] = round( 100 * ($result['latest'] - $result['peak']) / $result['peak'], 1 );
        }
        // Count consecutive declining steps walking back from the newest bucket.
        for ($i = count( $buckets ) - 1; $i > 0; $i--) {
            if ( $buckets[$i] < $buckets[$i - 1] ) {
                $result['declining_periods']++;
            } else {
                break;
            }
        }
        return $result;
    }

}
if ( !function_exists( 'cpanalyzer_get_decay_type_label' ) ) {
    /**
     * Human-readable label for a decay diagnosis type.
     */
    function cpanalyzer_get_decay_type_label(  $type  ) {
        $labels = array(
            'ranking_decay' => __( 'Ranking decay', 'content-performance-analyzer' ),
            'zero_click'    => __( 'Zero-click capture', 'content-performance-analyzer' ),
            'demand_decay'  => __( 'Demand decay', 'content-performance-analyzer' ),
            'seasonal'      => __( 'Seasonal pattern (expected)', 'content-performance-analyzer' ),
            'technical'     => __( 'Possible technical/indexing issue', 'content-performance-analyzer' ),
            'tracking'      => __( 'Possible tracking break', 'content-performance-analyzer' ),
            'non_search'    => __( 'Non-search drop', 'content-performance-analyzer' ),
            'mixed'         => __( 'Mixed signals', 'content-performance-analyzer' ),
            'unconfirmed'   => __( 'Unconfirmed drop', 'content-performance-analyzer' ),
            'none'          => __( 'No decay detected', 'content-performance-analyzer' ),
        );
        return $labels[$type] ?? $labels['none'];
    }

}
if ( !function_exists( 'cpanalyzer_classify_decay_type' ) ) {
    /**
     * Deterministic decay-type diagnosis from GA4/GSC period deltas.
     *
     * Different decay types need different fixes, so a single "decay" flag is
     * not enough: ranking decay wants a content refresh, zero-click capture
     * wants citation-ready restructuring, demand decay wants consolidation —
     * and a non-search drop is not an SEO problem at all. Classification is
     * pure math on the two collected periods; it never guesses.
     *
     * @param array $ga_data          GA4 row (views, views_prev, ...).
     * @param array $gsc_data         GSC row (clicks, clicks_prev, impressions, impressions_prev, position, position_prev, trend).
     * @param int   $post_modified_ts GMT timestamp of the page's last edit, 0 if unknown.
     * @return array{type:string,label:string,confidence:string,evidence:string,fix_hint:string,recent_edit:bool,trend_note:string}
     */
    function cpanalyzer_classify_decay_type(  $ga_data, $gsc_data, $post_modified_ts = 0  ) {
        $views = (int) ($ga_data['views'] ?? 0);
        $views_prev = (int) ($ga_data['views_prev'] ?? 0);
        $clicks = (int) ($gsc_data['clicks'] ?? 0);
        $clicks_prev = (int) ($gsc_data['clicks_prev'] ?? 0);
        $imp = (int) ($gsc_data['impressions'] ?? 0);
        $imp_prev = (int) ($gsc_data['impressions_prev'] ?? 0);
        $pos = (float) ($gsc_data['position'] ?? 0);
        $pos_prev = (float) ($gsc_data['position_prev'] ?? 0);
        $diagnosis = array(
            'type'        => 'none',
            'label'       => cpanalyzer_get_decay_type_label( 'none' ),
            'confidence'  => 'likely',
            'evidence'    => '',
            'fix_hint'    => '',
            'recent_edit' => false,
            'trend_note'  => '',
        );
        $clicks_delta_pct = ( $clicks_prev > 0 ? round( 100 * ($clicks - $clicks_prev) / $clicks_prev, 1 ) : null );
        $imp_delta_pct = ( $imp_prev > 0 ? round( 100 * ($imp - $imp_prev) / $imp_prev, 1 ) : null );
        $views_delta_pct = ( $views_prev > 0 ? round( 100 * ($views - $views_prev) / $views_prev, 1 ) : null );
        $pos_delta = ( $pos > 0 && $pos_prev > 0 ? round( $pos - $pos_prev, 1 ) : null );
        // Floors keep noise out: tiny pages produce huge percentages.
        $gsc_declined = $clicks_prev >= 10 && null !== $clicks_delta_pct && $clicks_delta_pct <= -20;
        $ga_declined = $views_prev >= 100 && null !== $views_delta_pct && $views_delta_pct <= -25;
        if ( !$gsc_declined && !$ga_declined ) {
            return $diagnosis;
        }
        // Position moves need both periods; treat >= 1.5 spots as a real slide.
        $pos_worse = null !== $pos_delta && $pos_delta >= 1.5;
        $pos_stable = null !== $pos_delta && abs( $pos_delta ) < 1.5;
        $imp_down = null !== $imp_delta_pct && $imp_prev >= 50 && $imp_delta_pct <= -20;
        $imp_stable_up = null !== $imp_delta_pct && $imp_delta_pct > -10;
        $gsc_evidence = '';
        if ( null !== $clicks_delta_pct ) {
            $gsc_evidence = sprintf(
                /* translators: 1: previous clicks; 2: current clicks; 3: clicks delta percent; 4: impressions delta description; 5: position description */
                __( 'Clicks %1$d → %2$d (%3$s%%), impressions %4$s, position %5$s.', 'content-performance-analyzer' ),
                $clicks_prev,
                $clicks,
                $clicks_delta_pct,
                ( null !== $imp_delta_pct ? sprintf( '%+.1f%%', $imp_delta_pct ) : __( 'n/a', 'content-performance-analyzer' ) ),
                ( null !== $pos_delta ? sprintf( '%.1f → %.1f', $pos_prev, $pos ) : __( 'n/a', 'content-performance-analyzer' ) )
            );
        }
        // Technical/indexing collapse comes first: impressions vanishing is
        // not "demand" — it is what deindexing, a robots block, or a lost
        // canonical looks like. Misreading it as demand decay prescribes a
        // redirect for a page that just needs its index status fixed.
        if ( $imp_prev >= 100 && $imp <= max( 5, (int) round( $imp_prev * 0.05 ) ) ) {
            $diagnosis['type'] = 'technical';
            $diagnosis['confidence'] = 'likely';
            $diagnosis['fix_hint'] = __( 'Impressions collapsed to near zero — that pattern points at indexing, not content. Verify the page in Search Console URL Inspection and check for noindex tags, robots.txt blocks, or a canonical pointing elsewhere before any content work.', 'content-performance-analyzer' );
            $diagnosis['evidence'] = sprintf( 
                /* translators: 1: previous impressions; 2: current impressions */
                __( 'Impressions collapsed %1$d → %2$d — a near-total loss of search visibility, not a gradual decline.', 'content-performance-analyzer' ),
                $imp_prev,
                $imp
             );
        } elseif ( $gsc_declined ) {
            if ( $pos_worse ) {
                $diagnosis['type'] = 'ranking_decay';
                $diagnosis['confidence'] = ( $pos_delta >= 3 ? 'confirmed' : 'likely' );
                $diagnosis['fix_hint'] = __( 'Rankings slid — refresh and deepen the content; competitors likely overtook it. Check for cannibalization before rewriting.', 'content-performance-analyzer' );
            } elseif ( $imp_stable_up && (null === $pos_delta || $pos_delta < 1.5) ) {
                $diagnosis['type'] = 'zero_click';
                $diagnosis['confidence'] = 'likely';
                $diagnosis['fix_hint'] = __( 'The page still ranks and is still seen, but the SERP is likely answering the query before the click. Make the page citation-ready and add value a snippet cannot replace.', 'content-performance-analyzer' );
            } elseif ( $imp_down && !$pos_worse ) {
                $diagnosis['type'] = 'demand_decay';
                $diagnosis['confidence'] = ( $imp_delta_pct <= -30 && ($pos_stable || null !== $pos_delta && $pos_delta < 0) ? 'confirmed' : 'likely' );
                $diagnosis['fix_hint'] = __( 'Rankings held but fewer people search this topic. A rewrite will not create demand — consider consolidating into a broader page or redirecting.', 'content-performance-analyzer' );
            } else {
                $diagnosis['type'] = 'mixed';
                $diagnosis['fix_hint'] = __( 'Several signals moved at once — review the SERP and the page manually before choosing a fix.', 'content-performance-analyzer' );
            }
            $diagnosis['evidence'] = $gsc_evidence;
            // Seasonality control: before prescribing consolidation for a
            // "dying" topic, compare against the SAME window one year ago.
            // A swimsuit page dipping every November is expected, not decay.
            $imp_yoy = (int) ($gsc_data['impressions_yoy'] ?? 0);
            if ( in_array( $diagnosis['type'], array('demand_decay', 'mixed'), true ) && $imp_yoy >= 50 ) {
                $yoy_ratio = $imp / max( 1, $imp_yoy );
                if ( $yoy_ratio >= 0.7 && $yoy_ratio <= 1.4 ) {
                    $diagnosis['type'] = 'seasonal';
                    $diagnosis['confidence'] = 'likely';
                    $diagnosis['fix_hint'] = __( 'This drop matches the same period last year — a seasonal pattern, not decay. No action needed; expect recovery when the season returns, and re-check then.', 'content-performance-analyzer' );
                    $diagnosis['evidence'] .= ' ' . sprintf( 
                        /* translators: 1: current impressions; 2: impressions in the same window last year */
                        __( 'Impressions are in line with the same window last year (%1$d now vs %2$d then).', 'content-performance-analyzer' ),
                        $imp,
                        $imp_yoy
                     );
                }
            }
        } elseif ( $ga_declined ) {
            if ( $clicks_prev >= 10 ) {
                $ga_evidence = sprintf(
                    /* translators: 1: previous views; 2: current views; 3: views delta percent; 4: clicks delta percent */
                    __( 'GA4 views %1$d → %2$d (%3$s%%) while Search Console clicks held (%4$s%%).', 'content-performance-analyzer' ),
                    $views_prev,
                    $views,
                    $views_delta_pct,
                    ( null !== $clicks_delta_pct ? $clicks_delta_pct : '0' )
                );
                if ( $views <= max( 5, (int) round( $views_prev * 0.02 ) ) ) {
                    // Near-total GA4 collapse while search clicks keep
                    // arriving is a broken tag, not a traffic story.
                    $diagnosis['type'] = 'tracking';
                    $diagnosis['confidence'] = 'likely';
                    $diagnosis['fix_hint'] = __( 'GA4 views collapsed to near zero while Search Console still records clicks — visitors are arriving but GA4 is not seeing them. Check that the GA4 tag still fires on this page (template change, consent banner, tag manager edit) before treating this as a traffic loss.', 'content-performance-analyzer' );
                    $diagnosis['evidence'] = $ga_evidence;
                } else {
                    // Search held steady while GA4 fell — not an SEO problem.
                    $diagnosis['type'] = 'non_search';
                    $diagnosis['confidence'] = 'confirmed';
                    $diagnosis['fix_hint'] = __( 'Organic search is healthy for this page — the traffic drop came from another channel. Check campaigns, referral sources, email, or social before touching the content.', 'content-performance-analyzer' );
                    $diagnosis['evidence'] = $ga_evidence;
                }
            } elseif ( $imp_prev >= 50 || $imp >= 50 ) {
                // The page HAS a search footprint (real impressions) but draws
                // almost no clicks, while its GA4 traffic was substantial and
                // dropped. Its visitors are not coming from Google search, so
                // the drop is a channel issue — a content refresh will not fix
                // it. GA4 views count every channel; GSC clicks count only
                // Google organic search.
                $diagnosis['type'] = 'non_search';
                $diagnosis['confidence'] = 'likely';
                $diagnosis['fix_hint'] = __( 'This page gets very little traffic from Google search — its visitors come from another channel, so a content refresh will not recover the drop. Open GA4 Traffic Acquisition (the channel breakdown) for this page to find which source fell: direct, email, social, referral, or internal links.', 'content-performance-analyzer' );
                $diagnosis['evidence'] = sprintf(
                    /* translators: 1: previous views; 2: current views; 3: views delta percent; 4: previous impressions; 5: previous clicks */
                    __( 'GA4 views %1$d → %2$d (%3$s%%), but the page drew only %5$d Google search clicks on %4$d impressions last period — its traffic is not search-driven.', 'content-performance-analyzer' ),
                    $views_prev,
                    $views,
                    $views_delta_pct,
                    $imp_prev,
                    $clicks_prev
                );
            } else {
                // Almost no search footprint at all. Most likely a non-search
                // page, but this pattern also fits a page that is not indexed
                // or whose search data was not captured — so stay cautious and
                // point at both checks rather than guessing a type.
                $diagnosis['type'] = 'unconfirmed';
                $diagnosis['confidence'] = 'weak';
                $diagnosis['fix_hint'] = __( 'This page has almost no Google search presence, so its GA4 drop most likely came from another channel — direct, email, social, referral, or internal links. Check your GA4 channel breakdown for this page. If you expected it to rank, confirm it is indexed in Search Console.', 'content-performance-analyzer' );
                $diagnosis['evidence'] = sprintf(
                    /* translators: 1: previous views; 2: current views; 3: views delta percent */
                    __( 'GA4 views %1$d → %2$d (%3$s%%); the page has negligible Google search clicks, so the drop cannot be attributed to search.', 'content-performance-analyzer' ),
                    $views_prev,
                    $views,
                    $views_delta_pct
                );
            }
        }
        $diagnosis['label'] = cpanalyzer_get_decay_type_label( $diagnosis['type'] );
        // Self-inflicted marker: the page was edited inside the drop window.
        if ( $post_modified_ts > 0 && $post_modified_ts >= strtotime( '-30 days' ) && in_array( $diagnosis['type'], array(
            'ranking_decay',
            'mixed',
            'zero_click',
            'demand_decay',
            'technical'
        ), true ) ) {
            $diagnosis['recent_edit'] = true;
            $diagnosis['evidence'] .= ' ' . sprintf( 
                /* translators: %s: date of last edit */
                __( 'Note: this page was edited on %s, inside the drop window — compare the old and new versions before assuming natural decay.', 'content-performance-analyzer' ),
                gmdate( 'M j, Y', $post_modified_ts )
             );
        }
        // Long-window context from the 6-period click trend, when collected.
        if ( !empty( $gsc_data['trend'] ) && is_array( $gsc_data['trend'] ) ) {
            $trend = cpanalyzer_analyze_click_trend( $gsc_data['trend'] );
            if ( $trend['declining_periods'] >= 3 ) {
                $diagnosis['trend_note'] = sprintf( 
                    /* translators: %d: number of consecutive declining 28-day periods */
                    __( 'Sustained decline: clicks have fallen for %d consecutive 28-day periods.', 'content-performance-analyzer' ),
                    $trend['declining_periods']
                 );
            } elseif ( $trend['vs_peak_pct'] <= -50 && $trend['peak'] >= 20 ) {
                $diagnosis['trend_note'] = sprintf( 
                    /* translators: 1: percent below peak; 2: peak clicks */
                    __( 'Clicks are %1$s%% below their 6-month peak of %2$d per period.', 'content-performance-analyzer' ),
                    $trend['vs_peak_pct'],
                    $trend['peak']
                 );
            }
        }
        return $diagnosis;
    }

}
if ( !function_exists( 'cpanalyzer_apply_technical_signals' ) ) {
    /**
     * Sharpen a decay diagnosis with technical evidence from the live page
     * scrape: a noindex tag or a canonical pointing elsewhere IS the root
     * cause for a traffic-losing page, and outranks any content diagnosis.
     *
     * @param array $diagnosis    Result of cpanalyzer_classify_decay_type().
     * @param array $content_data Scraped content analysis for the same page.
     * @return array
     */
    function cpanalyzer_apply_technical_signals(  $diagnosis, $content_data  ) {
        if ( empty( $diagnosis['type'] ) || 'none' === $diagnosis['type'] || empty( $content_data ) || !is_array( $content_data ) ) {
            return $diagnosis;
        }
        $declining_types = array(
            'ranking_decay',
            'zero_click',
            'demand_decay',
            'mixed',
            'technical',
            'unconfirmed'
        );
        if ( !in_array( $diagnosis['type'], $declining_types, true ) ) {
            return $diagnosis;
        }
        if ( !empty( $content_data['has_noindex'] ) ) {
            $diagnosis['type'] = 'technical';
            $diagnosis['label'] = cpanalyzer_get_decay_type_label( 'technical' );
            $diagnosis['confidence'] = 'confirmed';
            $diagnosis['evidence'] .= ' ' . __( 'Confirmed on the live page: it carries a noindex meta tag, telling search engines not to index it.', 'content-performance-analyzer' );
            $diagnosis['fix_hint'] = __( 'Remove the noindex tag (or confirm it is intentional). No content fix can help while the page tells search engines to exclude it.', 'content-performance-analyzer' );
            return $diagnosis;
        }
        if ( !empty( $content_data['canonical_mismatch'] ) ) {
            $canonical = (string) ($content_data['canonical_url'] ?? '');
            $diagnosis['evidence'] .= ' ' . sprintf( 
                /* translators: %s: the canonical URL found on the page */
                __( 'The live page declares a canonical pointing at a different URL (%s) — its ranking signals are being consolidated elsewhere. Verify this is intentional before any content work.', 'content-performance-analyzer' ),
                $canonical
             );
            if ( 'technical' === $diagnosis['type'] ) {
                $diagnosis['confidence'] = 'confirmed';
            }
        }
        return $diagnosis;
    }

}
if ( !function_exists( 'cpanalyzer_refine_decay_with_serp' ) ) {
    /**
     * Sharpen a decay diagnosis with live SERP features from Serper.
     *
     * @param array $diagnosis Result of cpanalyzer_classify_decay_type().
     * @param array $features  Result of CPAP_Serper_Collector::get_serp_features().
     * @return array
     */
    function cpanalyzer_refine_decay_with_serp(  $diagnosis, $features  ) {
        if ( empty( $features ) || !is_array( $features ) || empty( $diagnosis['type'] ) || 'none' === $diagnosis['type'] ) {
            return $diagnosis;
        }
        $keyword = (string) ($features['keyword'] ?? '');
        if ( 'zero_click' === $diagnosis['type'] && (!empty( $features['answer_box'] ) || !empty( $features['ai_overview'] )) ) {
            $diagnosis['confidence'] = 'confirmed';
            $diagnosis['evidence'] .= ' ' . sprintf( 
                /* translators: 1: "an AI Overview" or "an answer box"; 2: the keyword checked on the live SERP */
                __( 'Confirmed on the live SERP: %1$s sits above the organic results for "%2$s".', 'content-performance-analyzer' ),
                ( !empty( $features['ai_overview'] ) ? __( 'an AI Overview', 'content-performance-analyzer' ) : __( 'an answer box', 'content-performance-analyzer' ) ),
                $keyword
             );
        }
        if ( !empty( $features['non_article_top'] ) && (int) $features['non_article_top'] >= 3 ) {
            $diagnosis['trend_note'] = trim( $diagnosis['trend_note'] . ' ' . sprintf( 
                /* translators: 1: count of non-article results; 2: the keyword checked */
                __( 'Possible intent drift: %1$d of the top 10 results for "%2$s" are now video, forum, or commerce pages rather than articles — the SERP may want a different format.', 'content-performance-analyzer' ),
                (int) $features['non_article_top'],
                $keyword
             ) );
        }
        return $diagnosis;
    }

}
if ( !function_exists( 'cpanalyzer_get_inbound_link_map' ) ) {
    /**
     * Map of internal paths to the number of published posts/pages whose
     * content links to them. Menus, widgets, and theme templates are not
     * counted — this measures editorial links only, which is the signal
     * that matters for orphan detection and link equity.
     *
     * Cached for 12 hours; scans up to 3000 published items in chunks.
     *
     * @return array<string,int>
     */
    function cpanalyzer_get_inbound_link_map() {
        $cached = get_transient( 'cpanalyzer_inbound_link_map' );
        if ( is_array( $cached ) ) {
            return $cached;
        }
        global $wpdb;
        $post_types = get_post_types( array(
            'public' => true,
        ) );
        unset($post_types['attachment']);
        if ( empty( $post_types ) ) {
            return array();
        }
        $placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
        $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
        $map = array();
        $offset = 0;
        $chunk = 200;
        $max_posts = (int) apply_filters( 'cpanalyzer_inbound_link_scan_limit', 3000 );
        while ( $offset < $max_posts ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Chunked read-only scan; result cached in a transient.
            $rows = $wpdb->get_results( $wpdb->prepare( 
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Placeholders built from counted array.
                "SELECT ID, post_content FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$placeholders}) ORDER BY ID ASC LIMIT %d OFFSET %d",
                array_merge( array_values( $post_types ), array($chunk, $offset) )
             ) );
            if ( empty( $rows ) ) {
                break;
            }
            foreach ( $rows as $row ) {
                if ( false === strpos( (string) $row->post_content, 'href' ) ) {
                    continue;
                }
                $source_path = cpanalyzer_normalize_path( (string) get_permalink( (int) $row->ID ) );
                $seen = array();
                if ( !preg_match_all( '/href\\s*=\\s*["\']([^"\']+)["\']/i', (string) $row->post_content, $matches ) ) {
                    continue;
                }
                foreach ( $matches[1] as $href ) {
                    $href = html_entity_decode( $href );
                    // Internal links only: relative paths or same-host absolute URLs.
                    $host = wp_parse_url( $href, PHP_URL_HOST );
                    if ( null !== $host && strtolower( $host ) !== strtolower( (string) $site_host ) ) {
                        continue;
                    }
                    if ( 0 === strpos( $href, '#' ) || 0 === strpos( $href, 'mailto:' ) || 0 === strpos( $href, 'tel:' ) ) {
                        continue;
                    }
                    $target = cpanalyzer_normalize_path( $href );
                    if ( '' === $target || $target === $source_path || isset( $seen[$target] ) ) {
                        continue;
                    }
                    $seen[$target] = true;
                    $map[$target] = ($map[$target] ?? 0) + 1;
                }
            }
            $offset += $chunk;
            if ( count( $rows ) < $chunk ) {
                break;
            }
        }
        set_transient( 'cpanalyzer_inbound_link_map', $map, 12 * HOUR_IN_SECONDS );
        return $map;
    }

}
if ( !function_exists( 'cpanalyzer_output_faq_schema' ) ) {
    /**
     * Output FAQPage JSON-LD for Q&A pairs the plugin added to a post —
     * unless the page already carried FAQPage markup from another source
     * when the first pair was generated (recorded in post meta), so two
     * FAQPage blocks are never emitted.
     */
    function cpanalyzer_output_faq_schema() {
        if ( !is_singular() ) {
            return;
        }
        $post_id = get_the_ID();
        $pairs = get_post_meta( $post_id, '_cpanalyzer_faq', true );
        if ( empty( $pairs ) || !is_array( $pairs ) ) {
            return;
        }
        if ( (int) get_post_meta( $post_id, '_cpanalyzer_faq_external', true ) === 1 ) {
            return;
        }
        $entities = array();
        foreach ( $pairs as $pair ) {
            if ( empty( $pair['question'] ) || empty( $pair['answer'] ) ) {
                continue;
            }
            $entities[] = array(
                '@type'          => 'Question',
                'name'           => wp_strip_all_tags( (string) $pair['question'] ),
                'acceptedAnswer' => array(
                    '@type' => 'Answer',
                    'text'  => wp_strip_all_tags( (string) $pair['answer'] ),
                ),
            );
        }
        if ( empty( $entities ) ) {
            return;
        }
        $schema = array(
            '@context'   => 'https://schema.org',
            '@type'      => 'FAQPage',
            'mainEntity' => $entities,
        );
        echo '<script type="application/ld+json">' . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES ) . '</script>' . "\n";
    }

    add_action( 'wp_head', 'cpanalyzer_output_faq_schema' );
}
if ( !function_exists( 'cpanalyzer_psi_page_limit' ) ) {
    /**
     * How many pages may be hand-picked for PageSpeed analysis.
     *
     * The cap exists to keep an audit inside a sane runtime, not because the
     * PageSpeed service refuses more. Pro doubles it because those runs go
     * through the hosted collector, which paces and caches the requests.
     *
     * The premium check is deliberately nested rather than combined with &&,
     * so Freemius strips it cleanly from the free package.
     *
     * @return int
     */
    function cpanalyzer_psi_page_limit() {
        $limit = 15;
        if ( function_exists( 'cpanalyzer_has_pro_access__premium_only' ) ) {
            if ( cpanalyzer_has_pro_access__premium_only() ) {
                $limit = 30;
            }
        }
        /**
         * Filter the PageSpeed page-selection cap.
         *
         * @param int $limit Maximum selectable pages.
         */
        return (int) apply_filters( 'cpanalyzer_psi_page_limit', $limit );
    }

}
if ( !function_exists( 'cpanalyzer_page_age_display' ) ) {
    /**
     * Human-readable age of a page since its last content update.
     *
     * @param int $modified_ts Unix timestamp of post_modified_gmt (0 = unknown).
     * @return array{label: string, stale: bool} Empty label when unknown.
     */
    function cpanalyzer_page_age_display(  $modified_ts  ) {
        $modified_ts = (int) $modified_ts;
        if ( $modified_ts <= 0 || $modified_ts > time() ) {
            return array(
                'label' => '',
                'stale' => false,
            );
        }
        return array(
            'label' => human_time_diff( $modified_ts, time() ),
            'stale' => time() - $modified_ts > 18 * MONTH_IN_SECONDS,
        );
    }

}
if ( !function_exists( 'cpanalyzer_recovery_status' ) ) {
    /**
     * Verdict for a page marked as fixed: did the fix work?
     *
     * Pure function so it can be tested and reused (insights table,
     * dashboard widget) without touching the database. Watching window is
     * 28 days (a GSC/GA comparison needs at least one full period), the
     * final verdict lands by day 60.
     *
     * Signal preference: clicks when the page has any search footprint,
     * otherwise views. "Recovered" requires climbing back to ~90% of the
     * pre-decay peak (from the 6-bucket click trend captured at mark
     * time); "improving" is a 15% lift over the marked baseline with a
     * +5 absolute floor so single-digit noise never counts.
     *
     * @param array      $entry          {marked_at, baseline: {clicks, views}, peak_clicks}.
     * @param float|null $current_clicks Post-mark average clicks (snapshot history) or live value.
     * @param float|null $current_views  Post-mark average views or live value.
     * @param int|null   $now            Clock override for tests.
     * @return array{state: string, label: string, color: string, days: int}
     */
    function cpanalyzer_recovery_status(
        $entry,
        $current_clicks,
        $current_views,
        $now = null
    ) {
        $now = ( null === $now ? time() : (int) $now );
        $marked_at = (int) ($entry['marked_at'] ?? 0);
        if ( $marked_at <= 0 ) {
            return array(
                'state' => 'none',
                'label' => '',
                'color' => '',
                'days'  => 0,
            );
        }
        $days = (int) floor( max( 0, $now - $marked_at ) / DAY_IN_SECONDS );
        if ( $days < 28 ) {
            return array(
                'state' => 'watching',
                'label' => sprintf( 
                    /* translators: 1: days since the page was marked fixed, 2: days until the verdict */
                    __( 'Fixed %1$dd ago — verdict in %2$dd', 'content-performance-analyzer' ),
                    $days,
                    28 - $days
                 ),
                'color' => '#646970',
                'days'  => $days,
            );
        }
        $baseline_clicks = (float) ($entry['baseline']['clicks'] ?? 0);
        $baseline_views = (float) ($entry['baseline']['views'] ?? 0);
        $peak = (float) ($entry['peak_clicks'] ?? 0);
        // Prefer the search signal whenever the page has any search
        // footprint; fall back to views for pages that never had one.
        $current = null;
        $baseline = 0.0;
        if ( null !== $current_clicks && ($baseline_clicks > 0 || $peak > 0 || (float) $current_clicks > 0) ) {
            $current = (float) $current_clicks;
            $baseline = $baseline_clicks;
        } elseif ( null !== $current_views ) {
            $current = (float) $current_views;
            $baseline = $baseline_views;
            $peak = 0.0;
        }
        // No data at all is not the same as no improvement. Saying "no lift"
        // when nothing was ever measured blames the user's fix for a
        // measurement gap.
        if ( null === $current ) {
            return array(
                'state' => 'unmeasured',
                'label' => ( $days > 60 ? __( 'Fixed, but no traffic data was recorded to judge it — check the Google connection', 'content-performance-analyzer' ) : __( 'Fixed — waiting for traffic data to judge it', 'content-performance-analyzer' ) ),
                'color' => '#dba617',
                'days'  => $days,
            );
        }
        if ( $peak > $baseline * 1.1 && $current >= 0.9 * $peak ) {
            return array(
                'state' => 'recovered',
                'label' => sprintf( __( 'Recovered — back near its peak (%dd)', 'content-performance-analyzer' ), $days ),
                'color' => '#00a32a',
                'days'  => $days,
            );
        }
        if ( $current >= $baseline * 1.15 && $current >= $baseline + 5 ) {
            return array(
                'state' => 'improving',
                'label' => sprintf( __( 'Improving — above the pre-fix level (%dd)', 'content-performance-analyzer' ), $days ),
                'color' => '#2271b1',
                'days'  => $days,
            );
        }
        if ( $days <= 60 ) {
            return array(
                'state' => 'no_lift_yet',
                'label' => sprintf( __( 'No lift yet — watching until day 60 (%dd)', 'content-performance-analyzer' ), $days ),
                'color' => '#dba617',
                'days'  => $days,
            );
        }
        return array(
            'state' => 'no_lift',
            'label' => __( 'No measurable lift after 60 days', 'content-performance-analyzer' ),
            'color' => '#d63638',
            'days'  => $days,
        );
    }

}
if ( !function_exists( 'cpanalyzer_recovery_status_for' ) ) {
    /**
     * Full recovery status for a marked page.
     *
     * Post-fix averages come from the weekly snapshot history when rows
     * exist after the marked week (whole weeks cannot be faked by one good
     * day); the live row values are the fallback until the first post-fix
     * snapshot lands. Single source of truth for the insights table, the
     * dashboard widget, and the verdict notices.
     *
     * @param string   $url      URL key as used in the options arrays.
     * @param array    $entry    Entry from cpanalyzer_refreshed_map.
     * @param array    $gsc_rows The cpanalyzer_gsc_rows option value.
     * @param array    $ga_rows  The cpanalyzer_ga_rows option value.
     * @param int|null $now      Clock override for tests.
     * @return array{state: string, label: string, color: string, days: int}
     */
    function cpanalyzer_recovery_status_for(
        $url,
        $entry,
        $gsc_rows,
        $ga_rows,
        $now = null
    ) {
        $clicks = ( isset( $gsc_rows[$url]['clicks'] ) ? (float) $gsc_rows[$url]['clicks'] : null );
        $views = ( isset( $ga_rows[$url]['views'] ) ? (float) $ga_rows[$url]['views'] : null );
        if ( class_exists( 'CPAP_Snapshots' ) && !empty( $entry['marked_week'] ) ) {
            $avg = CPAP_Snapshots::get_average_since( (string) $url, (string) $entry['marked_week'] );
            if ( is_array( $avg ) ) {
                if ( null !== $avg['clicks'] ) {
                    $clicks = $avg['clicks'];
                }
                if ( null !== $avg['views'] ) {
                    $views = $avg['views'];
                }
            }
        }
        return cpanalyzer_recovery_status(
            $entry,
            $clicks,
            $views,
            $now
        );
    }

}
if ( !function_exists( 'cpanalyzer_get_page_seo_meta' ) ) {
    /**
     * Read a page's current SEO title and meta description from the site
     * itself — the active SEO plugin first, then the post.
     *
     * The content scraper reads these from rendered HTML, but it can fail
     * (blocked requests, local certificates, a page never rescanned). When
     * it has not run, the plugin must not conclude the fields are missing:
     * "we did not look" and "it is not there" are different facts, and
     * telling someone their titled page has no title destroys trust.
     *
     * Values containing unresolved template variables (%%sitename%%,
     * %title%) are reported as unknown rather than measured, since their
     * rendered length cannot be judged from the stored string.
     *
     * @param int $post_id Post ID.
     * @return array{title: string, description: string, source: string} Empty strings mean unknown.
     */
    function cpanalyzer_get_page_seo_meta(  $post_id  ) {
        $post_id = (int) $post_id;
        $result = array(
            'title'       => '',
            'description' => '',
            'source'      => '',
        );
        if ( $post_id < 1 || !get_post( $post_id ) ) {
            return $result;
        }
        $templated = static function ( $value ) {
            return is_string( $value ) && preg_match( '/%%?[a-z_]+%%?/i', $value );
        };
        // Post meta keys used by the major SEO plugins, best-supported first.
        $providers = array(
            'Rank Math' => array('rank_math_title', 'rank_math_description'),
            'Yoast SEO' => array('_yoast_wpseo_title', '_yoast_wpseo_metadesc'),
            'SEOPress'  => array('_seopress_titles_title', '_seopress_titles_desc'),
            'AIOSEO'    => array('_aioseo_title', '_aioseo_description'),
        );
        foreach ( $providers as $label => $keys ) {
            $title = (string) get_post_meta( $post_id, $keys[0], true );
            $desc = (string) get_post_meta( $post_id, $keys[1], true );
            if ( '' === trim( $title ) && '' === trim( $desc ) ) {
                continue;
            }
            $result['title'] = ( $templated( $title ) ? '' : trim( $title ) );
            $result['description'] = ( $templated( $desc ) ? '' : trim( $desc ) );
            $result['source'] = $label;
            break;
        }
        // The post title is a real, knowable title even with no SEO plugin.
        if ( '' === $result['title'] ) {
            $post_title = trim( (string) get_the_title( $post_id ) );
            if ( '' !== $post_title ) {
                $result['title'] = $post_title;
                $result['source'] = ( '' !== $result['source'] ? $result['source'] : __( 'post title', 'content-performance-analyzer' ) );
            }
        }
        // Excerpt is a deliberate summary, so it is a fair stand-in for a
        // meta description when no SEO plugin has set one.
        if ( '' === $result['description'] ) {
            $excerpt = trim( (string) get_post_field( 'post_excerpt', $post_id ) );
            if ( '' !== $excerpt ) {
                $result['description'] = $excerpt;
                $result['source'] = ( '' !== $result['source'] ? $result['source'] : __( 'post excerpt', 'content-performance-analyzer' ) );
            }
        }
        /**
         * Filter the SEO title and description read for a page.
         *
         * @param array $result  {title, description, source}.
         * @param int   $post_id Post ID.
         */
        return apply_filters( 'cpanalyzer_page_seo_meta', $result, $post_id );
    }

}
if ( !function_exists( 'cpanalyzer_get_ai_quota__premium_only' ) ) {
}
if ( !function_exists( 'cpanalyzer_evaluate_data_health' ) ) {
    /**
     * Look for whole data sources that came back empty, and for background
     * work that has silently stopped.
     *
     * The plugin's recurring failure mode has been knowing something is
     * wrong and not saying so: a Search Console property pointed at the
     * wrong site returns zero rows for every page, a blocked content scrape
     * leaves every page unanalyzed, and WP-Cron on a low-traffic site simply
     * never fires. Each one degrades every downstream feature while the
     * screen still looks fine. This computes those conditions so the
     * Insights page can say what is wrong and where to fix it.
     *
     * @param array|null $flags        Page flags, defaults to the stored option.
     * @param array|null $gsc_rows     GSC rows, defaults to the stored option.
     * @param array|null $content_data Content rows, defaults to the stored option.
     * @return array List of issues: {id, message, action_label, action_url}.
     */
    function cpanalyzer_evaluate_data_health(  $flags = null, $gsc_rows = null, $content_data = null  ) {
        $flags = ( is_array( $flags ) ? $flags : (array) get_option( 'cpanalyzer_page_flags', array() ) );
        $gsc_rows = ( is_array( $gsc_rows ) ? $gsc_rows : (array) get_option( 'cpanalyzer_gsc_rows', array() ) );
        $content_data = ( is_array( $content_data ) ? $content_data : (array) get_option( 'cpanalyzer_content_data', array() ) );
        $issues = array();
        $page_count = count( $flags );
        $settings_url = admin_url( 'admin.php?page=cpanalyzer-settings' );
        if ( $page_count < 1 ) {
            return $issues;
        }
        // Search Console returned nothing at all for any page.
        $connected = class_exists( 'CPAP_Google_OAuth' ) && ( new CPAP_Google_OAuth() )->is_connected();
        if ( $connected && !empty( $gsc_rows ) ) {
            $with_search = 0;
            foreach ( $gsc_rows as $row ) {
                if ( !is_array( $row ) ) {
                    continue;
                }
                if ( (int) ($row['impressions'] ?? 0) > 0 || (int) ($row['clicks'] ?? 0) > 0 ) {
                    $with_search++;
                }
            }
            if ( 0 === $with_search ) {
                $issues[] = array(
                    'id'           => 'gsc_empty',
                    'message'      => __( 'Search Console returned no impressions or clicks for any page. Usually the wrong property is selected — check that the Search Console site matches this website. Until then, keyword data, question gaps and CTR analysis stay empty.', 'content-performance-analyzer' ),
                    'action_label' => __( 'Check the Search Console property', 'content-performance-analyzer' ),
                    'action_url'   => $settings_url,
                );
            }
        }
        // The content scrape never produced anything usable.
        $analyzed = 0;
        foreach ( $content_data as $row ) {
            if ( is_array( $row ) && (array_key_exists( 'word_count', $row ) || array_key_exists( 'title', $row )) ) {
                $analyzed++;
            }
        }
        if ( 0 === $analyzed ) {
            $issues[] = array(
                'id'           => 'content_empty',
                'message'      => __( 'No page content could be read, so word counts, headings and answer-engine checks are unavailable. This is usually the plugin being unable to fetch your own pages — on a local or staging site, turning off "Verify SSL" in Settings fixes it. Rescan afterwards.', 'content-performance-analyzer' ),
                'action_label' => __( 'Open Settings', 'content-performance-analyzer' ),
                'action_url'   => $settings_url,
            );
        }
        // Weekly snapshots are the backbone of history and recovery
        // verdicts, and they only run when WP-Cron fires.
        $last_capture = (string) get_option( 'cpanalyzer_snapshot_last_capture', '' );
        if ( '' !== $last_capture ) {
            $days = (int) floor( (time() - (int) strtotime( $last_capture . ' UTC' )) / DAY_IN_SECONDS );
            if ( $days >= 10 ) {
                $issues[] = array(
                    'id'           => 'cron_stalled',
                    'message'      => sprintf( 
                        /* translators: %d: days since the last snapshot */
                        __( 'No performance snapshot has been recorded for %d days, so history and recovery verdicts are not advancing. WordPress only runs scheduled work when the site gets traffic — on a quiet site, ask your host to run wp-cron.php on a real schedule.', 'content-performance-analyzer' ),
                        $days
                     ),
                    'action_label' => '',
                    'action_url'   => '',
                );
            }
        }
        /**
         * Filter the detected data-health issues.
         *
         * @param array $issues Detected issues.
         */
        return apply_filters( 'cpanalyzer_data_health', $issues );
    }

}