<?php

/**
 * AI processing class for generating content recommendations (enhanced)
 *
 * @package ContentPerformanceAnalyzer
 * @since 1.0.0
 */
// Prevent direct access
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
if ( !class_exists( 'CPAP_AI_Processor' ) ) {
    class CPAP_AI_Processor {
        /**
         * Initialize AI processor
         */
        public function init() {
            // Hook for any initialization needed
        }

        /**
         * Lazy-loaded Serper collector instance
         */
        private $serper_collector = null;

        /**
         * Get Serper collector (only if class is loaded and key is set)
         *
         * @return CPAP_Serper_Collector|null
         */
        private function get_serper_collector() {
            if ( null === $this->serper_collector && class_exists( 'CPAP_Serper_Collector' ) ) {
                $this->serper_collector = new CPAP_Serper_Collector();
            }
            return $this->serper_collector;
        }

        /**
         * Get selected provider. Defaults to 'openai' for backward compatibility.
         *
         * @return string 'openai'|'groq'|'gemini'|'hosted'
         */
        private function cpanalyzer_get_ai_provider() {
            $provider = get_option( 'cpanalyzer_ai_provider', 'openai' );
            $provider = ( is_string( $provider ) ? strtolower( $provider ) : 'openai' );
            $allowed = array('openai', 'groq', 'gemini');
            return ( in_array( $provider, $allowed, true ) ? $provider : 'openai' );
        }

        /**
         * Map provider to base API URL and default model.
         *
         * Gemini is consumed through Google's OpenAI-compatible endpoint, and
         * the hosted relay speaks the same protocol, so the whole
         * chat-completion pipeline works unchanged for all four.
         *
         * @return array{ base: string, model: string }
         */
        private function cpanalyzer_get_ai_defaults() {
            $provider = $this->cpanalyzer_get_ai_provider();
            if ( 'groq' === $provider ) {
                return array(
                    'base'  => 'https://api.groq.com/openai/v1',
                    'model' => 'openai/gpt-oss-120b',
                );
            }
            if ( 'gemini' === $provider ) {
                return array(
                    'base'  => 'https://generativelanguage.googleapis.com/v1beta/openai',
                    'model' => 'gemini-2.5-flash',
                );
            }
            // OpenAI default — the flagship model: OpenAI is the paid path,
            // so it should deliver the strongest results of the three
            // providers. Accounts without flagship access fall back to the
            // mini tiers via the candidate chain.
            return array(
                'base'  => 'https://api.openai.com/v1',
                'model' => 'gpt-5.4',
            );
        }

        /**
         * Check if AI processor is properly configured
         *
         * @return bool True if the selected provider has usable credentials
         */
        public function is_configured() {
            $openai_key = $this->get_clean_api_key();
            return !empty( $openai_key ) && strlen( $openai_key ) > 20;
        }

        /**
         * "We can't generate anything yet" worded for the selected provider.
         *
         * Only a site that already has the hosted provider selected ever sees
         * the hosted wording, so this never advertises it to anyone who
         * cannot use it.
         *
         * @return string
         */
        public function get_not_configured_message() {
            return __( 'No AI API key configured. Add an OpenAI, Groq, or Gemini key in Settings.', 'content-performance-analyzer' );
        }

        /**
         * Get clean OpenAI API key with proper sanitization
         *
         * @return string Clean API key
         */
        private function get_clean_api_key() {
            $openai_key = get_option( 'cpanalyzer_openai_key', '' );
            if ( empty( $openai_key ) ) {
                return '';
            }
            // Remove any whitespace and invisible characters
            $openai_key = trim( $openai_key );
            // Remove any non-ASCII characters that might have been introduced
            $openai_key = preg_replace( '/[^\\x20-\\x7E]/', '', $openai_key );
            // Additional cleanup for common issues
            $openai_key = str_replace( array("\r", "\n", "\t"), '', $openai_key );
            return $openai_key;
        }

        /**
         * Return preferred and fallback model candidates for the selected provider.
         *
         * @param string $preferred_model Preferred model to try first.
         * @return array
         */
        private function cpanalyzer_get_ai_model_candidates( $preferred_model = '' ) {
            $defaults = $this->cpanalyzer_get_ai_defaults();
            $provider = $this->cpanalyzer_get_ai_provider();
            $candidates = array();
            $add_candidate = static function ( $model ) use(&$candidates) {
                $model = ( is_string( $model ) ? trim( $model ) : '' );
                if ( '' !== $model && !in_array( $model, $candidates, true ) ) {
                    $candidates[] = $model;
                }
            };
            // A user-set model override always wins — accounts often restrict
            // which models a project may use (OpenAI allowed-models lists),
            // making our defaults unusable for them.
            $add_candidate( get_option( 'cpanalyzer_ai_model', '' ) );
            $add_candidate( $preferred_model );
            $add_candidate( $defaults['model'] ?? '' );
            if ( 'groq' === $provider ) {
                $add_candidate( 'llama-3.3-70b-versatile' );
            } elseif ( 'gemini' === $provider ) {
                $add_candidate( 'gemini-2.0-flash' );
            } else {
                // Capability-descending so fallback degrades gracefully.
                $add_candidate( 'gpt-5.4-mini' );
                $add_candidate( 'gpt-5.4-nano' );
                $add_candidate( 'gpt-5.1-mini' );
            }
            return $candidates;
        }

        /**
         * Extract a readable error message from an AI HTTP response.
         *
         * @param mixed       $response wp_remote_post response.
         * @param array|null  $json     Decoded JSON body when available.
         * @return string
         */
        private function cpanalyzer_get_ai_error_message( $response, $json = null ) {
            if ( is_wp_error( $response ) ) {
                return $response->get_error_message();
            }
            if ( is_array( $json ) && !empty( $json['error']['message'] ) ) {
                return sanitize_text_field( $json['error']['message'] );
            }
            $body = trim( (string) wp_remote_retrieve_body( $response ) );
            if ( '' !== $body ) {
                return sanitize_text_field( mb_substr( $body, 0, 220 ) );
            }
            $code = (int) wp_remote_retrieve_response_code( $response );
            return ( $code > 0 ? 'HTTP ' . $code : 'Unknown API error' );
        }

        /**
         * Execute a chat completion request with model fallback for provider-safe defaults.
         *
         * @param array $messages Chat messages payload.
         * @param array $options  Additional payload options such as model/max_tokens/temperature.
         * @return string
         * @throws Exception When all model attempts fail.
         */
        private function cpanalyzer_request_chat_completion( $messages, $options = array() ) {
            $openai_key = $this->get_clean_api_key();
            if ( empty( $openai_key ) ) {
                throw new Exception('AI API key not configured.');
            }
            $provider = $this->cpanalyzer_get_ai_provider();
            $defaults = $this->cpanalyzer_get_ai_defaults();
            $url = apply_filters( 'cpanalyzer_ai_endpoint', trailingslashit( $defaults['base'] ) . 'chat/completions', $defaults );
            $payload = ( is_array( $options ) ? $options : array() );
            if ( empty( $payload['max_tokens'] ) ) {
                $payload['max_tokens'] = 1000;
            }
            if ( !isset( $payload['temperature'] ) ) {
                $payload['temperature'] = 0.3;
            }
            $payload['temperature'] = max( 1.0E-6, (float) $payload['temperature'] );
            $response_format = '';
            if ( isset( $payload['response_format'] ) && is_string( $payload['response_format'] ) && '' !== $payload['response_format'] ) {
                $response_format = $payload['response_format'];
            }
            $model_candidates = $this->cpanalyzer_get_ai_model_candidates( $payload['model'] ?? '' );
            // One entry per failed model so the final error names EVERY
            // candidate that failed and why — reporting only the last one
            // masked the real failure behind fallback noise.
            $errors = array();
            // When a structured response format is requested, try it first;
            // if every model rejects the parameter, retry once without it so
            // the call degrades to prompt-enforced JSON instead of failing.
            $format_variants = ( '' !== $response_format ? array($response_format, '') : array('') );
            // Every failed attempt against a rate-limited provider burns more
            // quota, so 429s must short-circuit instead of cascading through
            // retries and format variants (free Gemini/Groq tiers are tiny).
            $quota_exhausted = false;
            foreach ( $format_variants as $format_variant ) {
                foreach ( $model_candidates as $model ) {
                    // Newer OpenAI models reject legacy parameters (max_tokens
                    // must become max_completion_tokens; custom temperature is
                    // refused). Adapt and retry the SAME model instead of
                    // cascading to fallbacks the account may not even allow.
                    $use_completion_tokens = false;
                    $drop_temperature = false;
                    $adaptation_attempts = 0;
                    while ( $adaptation_attempts < 3 ) {
                        $adaptation_attempts++;
                        $request_body = array(
                            'model'    => $model,
                            'messages' => $messages,
                        );
                        if ( $use_completion_tokens ) {
                            $request_body['max_completion_tokens'] = $payload['max_tokens'];
                        } else {
                            $request_body['max_tokens'] = $payload['max_tokens'];
                        }
                        if ( !$drop_temperature ) {
                            $request_body['temperature'] = $payload['temperature'];
                        }
                        if ( '' !== $format_variant ) {
                            $request_body['response_format'] = array(
                                'type' => $format_variant,
                            );
                        }
                        $request_headers = array(
                            'Authorization' => 'Bearer ' . $openai_key,
                            'Content-Type'  => 'application/json',
                        );
                        $args = array(
                            'headers' => $request_headers,
                            'timeout' => $payload['timeout'] ?? 60,
                            'body'    => wp_json_encode( apply_filters( 'cpanalyzer_ai_payload', $request_body, $defaults ) ),
                        );
                        $args = apply_filters( 'cpanalyzer_ai_http_args', $args, $defaults );
                        $response = wp_remote_post( $url, $args );
                        $code = (int) wp_remote_retrieve_response_code( $response );
                        // Transient server errors get one immediate retry. Rate
                        // limits do NOT — an instant retry both fails and digs
                        // the quota hole deeper.
                        if ( $code >= 500 && $code <= 504 ) {
                            $retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
                            if ( $retry_after <= 0 ) {
                                $retry_after = 2;
                            }
                            sleep( min( $retry_after, 10 ) );
                            $response = wp_remote_post( $url, $args );
                        }
                        if ( is_wp_error( $response ) ) {
                            $errors[$model] = esc_html( $response->get_error_message() );
                            break;
                        }
                        $body = wp_remote_retrieve_body( $response );
                        $json = json_decode( $body, true );
                        $code = (int) wp_remote_retrieve_response_code( $response );
                        if ( 429 === $code ) {
                            $quota_exhausted = true;
                            // Both strings must keep the words "rate limit" —
                            // the auto-drain reads them to decide between
                            // backing off and stopping (CPAP_Admin::run_ai_drain).
                            $errors[$model] = __( 'rate limit reached — free AI tiers allow only a few requests per minute/day; wait a minute, process fewer pages, or add billing', 'content-performance-analyzer' );
                            break;
                        }
                        if ( 200 !== $code ) {
                            $error_message = $this->cpanalyzer_get_ai_error_message( $response, $json );
                            // Parameter-compatibility adaptations, one each.
                            if ( !$use_completion_tokens && false !== stripos( $error_message, 'max_tokens' ) && false !== stripos( $error_message, 'max_completion_tokens' ) ) {
                                $use_completion_tokens = true;
                                continue;
                            }
                            if ( !$drop_temperature && false !== stripos( $error_message, 'temperature' ) && (false !== stripos( $error_message, 'not support' ) || false !== stripos( $error_message, 'unsupported' )) ) {
                                $drop_temperature = true;
                                continue;
                            }
                            $errors[$model] = esc_html( $error_message );
                            break;
                        }
                        if ( !isset( $json['choices'][0]['message']['content'] ) ) {
                            $errors[$model] = 'invalid API response';
                            break;
                        }
                        // Record which model actually answered — fallbacks are
                        // silent, so this is the only honest provenance signal.
                        // The hosted relay pins the real model server-side, so
                        // trust the response over the 'auto' we asked for.
                        $responded_model = ( isset( $json['model'] ) ? sanitize_text_field( (string) $json['model'] ) : '' );
                        $trust_response_model = 'auto' === $model;
                        $this->last_model_used = ( '' !== $responded_model && $trust_response_model ? $responded_model : $model );
                        return $json['choices'][0]['message']['content'];
                    }
                    // A 429 still lets the NEXT model try once (separate
                    // quota) — only repeat passes are cut off below.
                }
                // Quota failures are not format failures — the no-format
                // second pass would just burn more rate-limited requests.
                if ( $quota_exhausted ) {
                    break;
                }
            }
            // Name every model that failed, in the order tried.
            $error_parts = array();
            foreach ( $errors as $failed_model => $failed_message ) {
                $error_parts[] = $failed_model . ': ' . $failed_message;
            }
            throw new Exception(( empty( $error_parts ) ? 'AI request failed.' : sprintf( 'All AI models failed — %s', implode( ' | ', array_slice( $error_parts, 0, 4 ) ) ) ));
        }

        /**
         * Generate concise alt text for an image from its file name and the
         * page context. Display code must never invent image data — this is
         * only called for images the scraper actually found missing alt text.
         *
         * @param  string $image_url Full URL of the image.
         * @param  array  $context   Optional keys: page_title, keyword, path.
         * @return string|\WP_Error  Alt text (<=150 chars) or WP_Error.
         */
        public function generate_image_alt_text( $image_url, $context = array() ) {
            $filename = wp_basename( (string) wp_parse_url( (string) $image_url, PHP_URL_PATH ) );
            /**
             * Short-circuit AI alt-text generation (used for testing and by
             * integrations that supply their own alt text).
             *
             * @param string|null $alt       Non-empty string to use as-is.
             * @param string      $image_url Image URL.
             * @param array       $context   Page context.
             */
            $pre = apply_filters(
                'cpanalyzer_pre_generate_alt_text',
                null,
                $image_url,
                $context
            );
            if ( is_string( $pre ) && '' !== trim( $pre ) ) {
                return sanitize_text_field( substr( trim( $pre ), 0, 150 ) );
            }
            if ( !$this->is_configured() ) {
                return new WP_Error('ai_not_configured', $this->get_not_configured_message());
            }
            $page_title = sanitize_text_field( (string) ($context['page_title'] ?? '') );
            $keyword = sanitize_text_field( (string) ($context['keyword'] ?? '') );
            $context_lines = '';
            if ( '' !== $page_title ) {
                $context_lines .= 'Page title: ' . $page_title . "\n";
            }
            if ( '' !== $keyword ) {
                $context_lines .= 'Page topic: ' . $keyword . "\n";
            }
            $output_rules = 'Respond with the alt text only — no quotes, no "Image of" prefix. Do not stuff keywords; mention the page topic only when it genuinely fits the image.';
            // Vision path — OpenAI and Gemini models accept images through the
            // same chat-completions format. The image is downloaded and sent
            // inline as base64 so private/dev-site images work too. Groq's
            // text-only model skips straight to the filename-based prompt.
            $provider = $this->cpanalyzer_get_ai_provider();
            if ( in_array( $provider, array('openai', 'gemini'), true ) ) {
                $image_data_uri = $this->cpanalyzer_get_image_data_uri( $image_url );
                if ( '' !== $image_data_uri ) {
                    $vision_prompt = "Write concise, descriptive alt text (maximum 120 characters) for this image from a web page.\n";
                    $vision_prompt .= $context_lines;
                    $vision_prompt .= 'Describe what the image shows for a screen-reader user. ' . $output_rules;
                    try {
                        $raw = $this->cpanalyzer_request_chat_completion( array(array(
                            'role'    => 'user',
                            'content' => array(array(
                                'type' => 'text',
                                'text' => $vision_prompt,
                            ), array(
                                'type'      => 'image_url',
                                'image_url' => array(
                                    'url' => $image_data_uri,
                                ),
                            )),
                        )), array(
                            'max_tokens'  => 200,
                            'temperature' => 0.4,
                        ) );
                        $alt = $this->cpanalyzer_clean_alt_text( $raw );
                        if ( '' !== $alt ) {
                            return $alt;
                        }
                    } catch ( Exception $e ) {
                        // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Vision failures fall back to the filename-based prompt below.
                        // Model rejected image input or the request failed —
                        // the text-only path below still produces a result.
                    }
                }
            }
            // Text-only fallback: the model can't see the image here, so it
            // must not invent specifics the filename doesn't support.
            $prompt = "Write concise, descriptive alt text (maximum 120 characters) for an image on a web page.\n";
            $prompt .= 'Image file name: ' . $filename . "\n";
            $prompt .= $context_lines;
            $prompt .= 'Describe what the image most likely shows for a screen-reader user. If the file name does not clearly indicate the subject, write conservative, general alt text based on the page topic instead of inventing specific details such as people, places, or numbers. ' . $output_rules;
            try {
                $raw = $this->cpanalyzer_request_chat_completion( array(array(
                    'role'    => 'user',
                    'content' => $prompt,
                )), array(
                    'max_tokens'  => 200,
                    'temperature' => 0.4,
                ) );
            } catch ( Exception $e ) {
                return new WP_Error('ai_request_failed', $e->getMessage());
            }
            $alt = $this->cpanalyzer_clean_alt_text( $raw );
            if ( '' === $alt ) {
                return new WP_Error('ai_empty_response', __( 'AI returned empty alt text.', 'content-performance-analyzer' ));
            }
            return $alt;
        }

        /**
         * Normalize a raw model response into safe, single-line alt text.
         *
         * @param  string $raw Raw completion text.
         * @return string Cleaned alt text, or empty string.
         */
        private function cpanalyzer_clean_alt_text( $raw ) {
            $alt = trim( str_replace( array("\r", "\n"), ' ', (string) $raw ), " \t\"'" );
            if ( '' === $alt ) {
                return '';
            }
            return sanitize_text_field( substr( $alt, 0, 150 ) );
        }

        /**
         * Download an image and return it as a base64 data URI for inline
         * vision input. Returns an empty string when the image can't be used
         * (unreachable, too large, or an unsupported type such as SVG) — the
         * caller then falls back to the filename-based prompt.
         *
         * @param  string $image_url Image URL.
         * @return string data: URI or empty string.
         */
        private function cpanalyzer_get_image_data_uri( $image_url ) {
            if ( 0 !== stripos( (string) $image_url, 'http' ) ) {
                return '';
            }
            $response = wp_remote_get( $image_url, array(
                'timeout' => 15,
            ) );
            if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
                return '';
            }
            $body = wp_remote_retrieve_body( $response );
            if ( '' === $body || strlen( $body ) > 3 * MB_IN_BYTES ) {
                return '';
            }
            $allowed = array(
                'image/jpeg',
                'image/png',
                'image/gif',
                'image/webp'
            );
            $mime = (string) wp_remote_retrieve_header( $response, 'content-type' );
            $mime = strtolower( trim( strtok( $mime, ';' ) ) );
            if ( !in_array( $mime, $allowed, true ) ) {
                $extension_map = array(
                    'jpg'  => 'image/jpeg',
                    'jpeg' => 'image/jpeg',
                    'png'  => 'image/png',
                    'gif'  => 'image/gif',
                    'webp' => 'image/webp',
                );
                $extension = strtolower( (string) pathinfo( (string) wp_parse_url( $image_url, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
                $mime = $extension_map[$extension] ?? '';
            }
            if ( '' === $mime ) {
                return '';
            }
            return 'data:' . $mime . ';base64,' . base64_encode( $body );
            // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Standard inline-image encoding for vision API input.
        }

        /**
         * Write a concise, snippet-shaped answer (40-60 words) to a search
         * query, grounded strictly in the page's own content. Returns
         * WP_Error 'insufficient_context' when the page doesn't contain the
         * information — the caller must not insert anything in that case.
         *
         * @param  string $query   The question-intent search query.
         * @param  array  $context Optional keys: page_title, body_text.
         * @return string|\WP_Error
         */
        public function generate_answer_block( $query, $context = array() ) {
            /**
             * Short-circuit answer generation (testing / integrations).
             *
             * @param string|null $answer  Non-empty string to use as-is.
             * @param string      $query   The search query.
             * @param array       $context Page context.
             */
            $pre = apply_filters(
                'cpanalyzer_pre_generate_answer',
                null,
                $query,
                $context
            );
            if ( is_string( $pre ) && '' !== trim( $pre ) ) {
                return sanitize_textarea_field( substr( trim( $pre ), 0, 600 ) );
            }
            if ( !$this->is_configured() ) {
                return new WP_Error('ai_not_configured', $this->get_not_configured_message());
            }
            $page_title = sanitize_text_field( (string) ($context['page_title'] ?? '') );
            $body_text = sanitize_textarea_field( mb_substr( (string) ($context['body_text'] ?? ''), 0, 3000 ) );
            $prompt = "Write a direct, factual answer of 40-60 words to this search query, suitable as the paragraph under an FAQ heading on a web page.\n";
            $prompt .= 'Query: ' . $query . "\n";
            if ( '' !== $page_title ) {
                $prompt .= 'Page title: ' . $page_title . "\n";
            }
            if ( '' !== $body_text ) {
                $prompt .= "Base the answer ONLY on this page content — do not invent facts, numbers, or claims that are not supported by it:\n" . $body_text . "\n";
            }
            $prompt .= 'If the page content does not contain the information needed to answer accurately, respond with exactly: INSUFFICIENT_CONTEXT. Otherwise respond with the answer text only — no heading, no quotes, no preamble.';
            try {
                $raw = $this->cpanalyzer_request_chat_completion( array(array(
                    'role'    => 'user',
                    'content' => $prompt,
                )), array(
                    'max_tokens'  => 250,
                    'temperature' => 0.3,
                ) );
            } catch ( Exception $e ) {
                return new WP_Error('ai_request_failed', $e->getMessage());
            }
            $answer = trim( str_replace( array("\r"), '', (string) $raw ), " \t\n\"'" );
            if ( '' === $answer || false !== stripos( $answer, 'INSUFFICIENT_CONTEXT' ) ) {
                return new WP_Error('insufficient_context', __( 'The page content doesn\'t contain enough information to answer this accurately — expand the page first.', 'content-performance-analyzer' ));
            }
            return sanitize_textarea_field( substr( $answer, 0, 600 ) );
        }

        /**
         * Call OpenAI API with a prompt
         *
         * @param string $prompt The prompt to send to OpenAI
         * @param array  $options Optional parameters (max_tokens, temperature, etc.)
         * @return string|false The response content or false on failure
         */
        public function call_openai_api( $prompt, $options = array() ) {
            $messages = array(array(
                'role'    => 'user',
                'content' => $prompt,
            ));
            try {
                return $this->cpanalyzer_request_chat_completion( $messages, $options );
            } catch ( Exception $e ) {
                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
                    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                    error_log( 'CPA AI request error: ' . $e->getMessage() );
                }
                return false;
            }
        }

        /**
         * Process flagged pages with AI (enhanced with content analysis)
         *
         * @param array $payload
         * @return bool
         */
        public function process_flagged_pages( $payload ) {
            if ( !$this->is_configured() ) {
                update_option( 'cpanalyzer_status_msg', $this->get_not_configured_message() );
                $this->last_generation_error = $this->get_not_configured_message();
                return false;
            }
            if ( empty( $payload ) ) {
                update_option( 'cpanalyzer_status_msg', __( 'No pages flagged for AI analysis.', 'content-performance-analyzer' ) );
                return false;
            }
            try {
                $all_actions = $this->generate_actions_for_payload( $payload );
                $this->save_ai_actions( $all_actions );
                $halt_error = $this->get_last_generation_error();
                if ( '' !== $halt_error && !empty( $all_actions ) ) {
                    $stats = $this->get_last_generation_stats();
                    update_option( 'cpanalyzer_status_msg', sprintf(
                        /* translators: 1: pages processed; 2: total pages; 3: the error that stopped the run */
                        __( 'AI recommendations saved for %1$d of %2$d pages, then stopped: %3$s Run again to continue with the remaining pages.', 'content-performance-analyzer' ),
                        $stats['processed'],
                        $stats['total'],
                        $halt_error
                    ) );
                    return true;
                }
                if ( '' !== $halt_error ) {
                    update_option( 'cpanalyzer_status_msg', sprintf( 
                        /* translators: %s: Error message */
                        __( 'AI processing error: %s', 'content-performance-analyzer' ),
                        esc_html( $halt_error )
                     ) );
                    return false;
                }
                update_option( 'cpanalyzer_status_msg', sprintf( 
                    /* translators: %d: Number of pages processed */
                    __( 'Processed %d pages with enhanced AI recommendations.', 'content-performance-analyzer' ),
                    count( $all_actions )
                 ) );
                return true;
            } catch ( Exception $e ) {
                update_option( 'cpanalyzer_status_msg', sprintf( 
                    /* translators: %s: Error message */
                    __( 'AI processing error: %s', 'content-performance-analyzer' ),
                    esc_html( $e->getMessage() )
                 ) );
                return false;
            }
        }

        /**
         * Process missing suggestions
         *
         * @param array $missing_payload
         * @return bool
         */
        public function process_missing_suggestions( $missing_payload ) {
            if ( !$this->is_configured() ) {
                update_option( 'cpanalyzer_status_msg', $this->get_not_configured_message() );
                $this->last_generation_error = $this->get_not_configured_message();
                return false;
            }
            try {
                $actions = $this->generate_actions_for_payload( $missing_payload, true );
                $this->merge_ai_actions( $actions );
                $halt_error = $this->get_last_generation_error();
                if ( '' !== $halt_error && !empty( $actions ) ) {
                    $stats = $this->get_last_generation_stats();
                    update_option( 'cpanalyzer_status_msg', sprintf(
                        /* translators: 1: pages processed; 2: total pages; 3: the error that stopped the run */
                        __( 'AI suggestions refreshed for %1$d of %2$d pages, then stopped: %3$s Run again to continue with the remaining pages.', 'content-performance-analyzer' ),
                        $stats['processed'],
                        $stats['total'],
                        $halt_error
                    ) );
                    return true;
                }
                if ( '' !== $halt_error ) {
                    update_option( 'cpanalyzer_status_msg', sprintf( 
                        /* translators: %s: error message */
                        __( 'AI processing error: %s', 'content-performance-analyzer' ),
                        esc_html( $halt_error )
                     ) );
                    return false;
                }
                update_option( 'cpanalyzer_status_msg', sprintf( 
                    /* translators: %d: number of URLs refreshed */
                    __( 'AI suggestions refreshed for %d URLs.', 'content-performance-analyzer' ),
                    count( $actions )
                 ) );
                return true;
            } catch ( Exception $e ) {
                update_option( 'cpanalyzer_status_msg', sprintf( 
                    /* translators: %s: error message */
                    __( 'AI processing error: %s', 'content-performance-analyzer' ),
                    esc_html( $e->getMessage() )
                 ) );
                // Log error for debugging (development only)
                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
                    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                    error_log( 'CPA AI Processing Error: ' . $e->getMessage() );
                }
                return false;
            }
        }

        /**
         * Generate normalized AI actions for a payload without persisting them.
         * This is the shared generation path for analytics mode, retries, and batch processing.
         *
         * @param array $payload  Raw payload items.
         * @param bool  $is_retry Whether this is a retry prompt.
         * @return array
         * @throws Exception When API processing fails.
         */
        public function generate_actions_for_payload( $payload, $is_retry = false ) {
            $this->last_generation_error = '';
            $this->last_generation_stats = array(
                'processed' => 0,
                'total'     => 0,
            );
            $openai_key = $this->get_clean_api_key();
            if ( !$this->is_configured() || empty( $payload ) || !is_array( $payload ) ) {
                return array();
            }
            $enhanced_payload = $this->enhance_payload_with_content( $payload );
            $actions = array();
            $batches = array_chunk( $enhanced_payload, 5 );
            $this->last_generation_stats['total'] = count( $enhanced_payload );
            /**
             * Seconds to pause between AI batch chunks. Defaults to 6 s on
             * Gemini, whose free tier allows ~10 requests/minute — pacing
             * keeps multi-chunk runs under the limit instead of tripping it.
             *
             * @param int    $delay    Seconds between chunks.
             * @param string $provider Active provider slug.
             */
            $chunk_delay = (int) apply_filters( 'cpanalyzer_ai_chunk_delay', ( 'gemini' === $this->cpanalyzer_get_ai_provider() ? 6 : 0 ), $this->cpanalyzer_get_ai_provider() );
            foreach ( $batches as $index => $batch ) {
                if ( $index > 0 && $chunk_delay > 0 ) {
                    sleep( min( $chunk_delay, 15 ) );
                }
                try {
                    $batch_actions = $this->process_ai_batch( $batch, $openai_key, $is_retry );
                } catch ( Exception $e ) {
                    // Keep everything completed so far — a rate limit halfway
                    // through must not discard the pages that DID process.
                    $this->last_generation_error = $e->getMessage();
                    break;
                }
                $actions = array_merge( $actions, $batch_actions );
                $this->last_generation_stats['processed'] += count( $batch );
            }
            return $actions;
        }

        /** @var string Model that produced the most recent successful response. */
        private $last_model_used = '';

        /** @var string Error that stopped the most recent generation run, if any. */
        private $last_generation_error = '';

        /** @var array{processed:int,total:int} Progress of the most recent run. */
        private $last_generation_stats = array(
            'processed' => 0,
            'total'     => 0,
        );

        /**
         * Error that halted the last generate_actions_for_payload() run
         * (empty string when it completed cleanly).
         *
         * @return string
         */
        public function get_last_generation_error() {
            return $this->last_generation_error;
        }

        /**
         * Progress of the last generation run: pages processed vs. total.
         *
         * @return array{processed:int,total:int}
         */
        public function get_last_generation_stats() {
            return $this->last_generation_stats;
        }

        /**
         * Enhance payload with GA, GSC, and content data
         *
         * @param array $payload
         * @return array
         */
        private function enhance_payload_with_content( $payload ) {
            // Get all available data
            $ga_rows = (array) get_option( 'cpanalyzer_ga_rows', array() );
            $ga_prev_rows = (array) get_option( 'cpanalyzer_ga_prev_rows', array() );
            $gsc_rows = (array) get_option( 'cpanalyzer_gsc_rows', array() );
            $content_data = (array) get_option( 'cpanalyzer_content_data', array() );
            $enhanced = array();
            foreach ( $payload as $item ) {
                $url = $item['url'];
                $enhanced_item = $item;
                $stored_ga_metrics = array();
                if ( isset( $ga_rows[$url] ) ) {
                    $ga_data = $ga_rows[$url];
                    $stored_ga_metrics = array(
                        'views'          => $ga_data['views'] ?? 0,
                        'uniques'        => $ga_data['uniques'] ?? 0,
                        'engagement'     => $ga_data['engagement'] ?? 0,
                        'duration'       => $ga_data['duration'] ?? 0,
                        'form_fills'     => $ga_data['form_fills'] ?? 0,
                        'views_previous' => ( isset( $ga_prev_rows[$url] ) ? $ga_prev_rows[$url]['views'] ?? 0 : 0 ),
                    );
                }
                $provided_ga_metrics = ( isset( $item['ga_metrics'] ) && is_array( $item['ga_metrics'] ) ? $item['ga_metrics'] : array() );
                if ( empty( $provided_ga_metrics ) ) {
                    $provided_ga_metrics = array(
                        'views'          => $item['views'] ?? null,
                        'uniques'        => $item['uniques'] ?? null,
                        'engagement'     => $item['engagement'] ?? null,
                        'duration'       => $item['duration'] ?? null,
                        'form_fills'     => $item['form_fills'] ?? null,
                        'views_previous' => $item['views_previous'] ?? $item['views_prev'] ?? null,
                    );
                }
                $enhanced_item['ga_metrics'] = $this->merge_present_values( array(
                    'views'          => 0,
                    'uniques'        => 0,
                    'engagement'     => 0,
                    'duration'       => 0,
                    'form_fills'     => 0,
                    'views_previous' => 0,
                ), $stored_ga_metrics );
                $enhanced_item['ga_metrics'] = $this->merge_present_values( $enhanced_item['ga_metrics'], $provided_ga_metrics );
                if ( empty( $enhanced_item['ga_metrics']['views_previous'] ) && isset( $enhanced_item['ga_metrics']['views_prev'] ) ) {
                    $enhanced_item['ga_metrics']['views_previous'] = $enhanced_item['ga_metrics']['views_prev'];
                }
                $stored_gsc_metrics = array();
                if ( isset( $gsc_rows[$url] ) ) {
                    $gsc_data = $gsc_rows[$url];
                    $stored_gsc_metrics = array(
                        'impressions'      => $gsc_data['impressions'] ?? 0,
                        'clicks'           => $gsc_data['clicks'] ?? 0,
                        'ctr'              => $gsc_data['ctr'] ?? 0,
                        'position'         => $gsc_data['position'] ?? 0,
                        'impressions_prev' => $gsc_data['impressions_prev'] ?? 0,
                        'clicks_prev'      => $gsc_data['clicks_prev'] ?? 0,
                        'position_prev'    => $gsc_data['position_prev'] ?? 0,
                        'top_keywords'     => array_slice( $gsc_data['keywords'] ?? array(), 0, 5 ),
                    );
                    // Retry-path items arrive with only url + flag: recover the
                    // decay intelligence persisted with the stored GSC row.
                    if ( empty( $enhanced_item['decay_diagnosis'] ) && !empty( $gsc_data['decay_diagnosis'] ) && 'none' !== ($gsc_data['decay_diagnosis']['type'] ?? 'none') ) {
                        $enhanced_item['decay_diagnosis'] = $gsc_data['decay_diagnosis'];
                    }
                    if ( empty( $enhanced_item['cannibalization'] ) && !empty( $gsc_data['cannibalization'] ) ) {
                        $enhanced_item['cannibalization'] = array_slice( (array) $gsc_data['cannibalization'], 0, 3 );
                    }
                    if ( empty( $enhanced_item['serp_features'] ) && !empty( $gsc_data['serp_features'] ) ) {
                        $enhanced_item['serp_features'] = $gsc_data['serp_features'];
                    }
                    if ( empty( $enhanced_item['click_trend'] ) && !empty( $gsc_data['trend'] ) && function_exists( 'cpanalyzer_analyze_click_trend' ) ) {
                        $enhanced_item['click_trend'] = cpanalyzer_analyze_click_trend( $gsc_data['trend'] );
                    }
                }
                $provided_gsc_metrics = ( isset( $item['gsc_metrics'] ) && is_array( $item['gsc_metrics'] ) ? $item['gsc_metrics'] : array() );
                if ( empty( $provided_gsc_metrics ) ) {
                    $provided_gsc_metrics = array(
                        'impressions'      => $item['impressions'] ?? null,
                        'clicks'           => $item['clicks'] ?? null,
                        'ctr'              => $item['ctr'] ?? null,
                        'position'         => $item['position'] ?? null,
                        'impressions_prev' => $item['impressions_prev'] ?? null,
                        'top_keywords'     => array_slice( (array) ($item['keywords'] ?? array()), 0, 5 ),
                    );
                }
                if ( empty( $provided_gsc_metrics['top_keywords'] ) && !empty( $provided_gsc_metrics['keywords'] ) ) {
                    $provided_gsc_metrics['top_keywords'] = array_slice( (array) $provided_gsc_metrics['keywords'], 0, 5 );
                }
                $enhanced_item['gsc_metrics'] = $this->merge_present_values( array(
                    'impressions'      => 0,
                    'clicks'           => 0,
                    'ctr'              => 0,
                    'position'         => 0,
                    'impressions_prev' => 0,
                    'top_keywords'     => array(),
                ), $stored_gsc_metrics );
                $enhanced_item['gsc_metrics'] = $this->merge_present_values( $enhanced_item['gsc_metrics'], $provided_gsc_metrics );
                if ( empty( $enhanced_item['gsc_metrics']['top_keywords'] ) ) {
                    $enhanced_item['gsc_metrics']['top_keywords'] = array();
                }
                $content_source = ( isset( $item['content_source'] ) && is_array( $item['content_source'] ) ? $item['content_source'] : array() );
                if ( empty( $content_source ) && isset( $content_data[$url] ) ) {
                    $content_source = $content_data[$url];
                }
                $provided_content_analysis = ( isset( $item['content_analysis'] ) && is_array( $item['content_analysis'] ) ? $item['content_analysis'] : array() );
                $stored_content_analysis = ( !empty( $content_source ) ? $this->build_content_analysis_from_source( $content_source ) : array() );
                $enhanced_item['content_analysis'] = $this->merge_present_values( $stored_content_analysis, $provided_content_analysis );
                // ── Serper Competitor Enrichment ──────────────────────────────────
                // Pull competitor headings + body text for the primary keyword.
                // Uses 24-hour transient cache to preserve Serper quota.
                $enhanced_item['serper_signals'] = array(
                    'related_keywords' => array(),
                    'paa'              => array(),
                );
                $serper = $this->get_serper_collector();
                if ( $serper && $serper->is_configured() ) {
                    $serper_keyword = $this->determine_serper_seed_keyword(
                        $item,
                        $enhanced_item,
                        $content_source,
                        $url
                    );
                    $keyword = $serper_keyword['keyword'];
                    $keyword_source = $serper_keyword['source'];
                    if ( !empty( $keyword ) ) {
                        $this->log_serper_debug_event( 'serper_keyword_selected', 'Selected keyword for Serper enrichment.', array(
                            'url'            => $url,
                            'keyword'        => $keyword,
                            'keyword_source' => $keyword_source,
                        ) );
                        $competitor_data = $serper->get_competitor_context_cached( $keyword, $url );
                        // Only attach if we got real competitor content
                        $enhanced_item['competitor_context'] = ( !empty( $competitor_data['competitors'] ) ? $competitor_data['competitors'] : array() );
                        $enhanced_item['serper_signals'] = array(
                            'related_keywords' => $this->sanitize_ai_string_array( $competitor_data['related_keywords'] ?? array(), 20, 120 ),
                            'paa'              => $this->sanitize_ai_string_array( $competitor_data['paa'] ?? array(), 20, 140 ),
                        );
                    } else {
                        $enhanced_item['competitor_context'] = array();
                        $this->log_serper_debug_event( 'serper_skipped_no_keyword', 'Skipped Serper enrichment because no usable keyword could be derived.', array(
                            'url' => $url,
                        ) );
                    }
                } else {
                    // Serper not configured — competitor context unavailable
                    $enhanced_item['competitor_context'] = array();
                    $this->log_serper_debug_event( 'serper_skipped_unconfigured', 'Skipped Serper enrichment because the API key is not configured.', array(
                        'url' => $url,
                    ) );
                }
                // ── End Serper Enrichment ─────────────────────────────────────────
                $enhanced[] = $enhanced_item;
            }
            return $enhanced;
        }

        /**
         * Merge only present values into a base array.
         *
         * @param array $base      Base array.
         * @param array $overrides Override values.
         * @return array
         */
        private function merge_present_values( $base, $overrides ) {
            if ( !is_array( $base ) ) {
                $base = array();
            }
            if ( !is_array( $overrides ) ) {
                return $base;
            }
            foreach ( $overrides as $key => $value ) {
                if ( is_array( $value ) ) {
                    if ( !empty( $value ) ) {
                        $base[$key] = $value;
                    }
                    continue;
                }
                if ( null !== $value && '' !== $value ) {
                    $base[$key] = $value;
                }
            }
            return $base;
        }

        /**
         * Log a temporary Serper debug event through the core event logger.
         *
         * @param string $event
         * @param string $message
         * @param array  $data
         * @return void
         */
        private function log_serper_debug_event( $event, $message, $data = array() ) {
            if ( class_exists( 'CPAP_Core' ) && method_exists( 'CPAP_Core', 'get_instance' ) ) {
                CPAP_Core::get_instance()->log_event( $event, $message, ( is_array( $data ) ? $data : array() ) );
            }
        }

        /**
         * Choose the best available keyword seed for Serper enrichment.
         *
         * @param array  $item          Raw source item.
         * @param array  $enhanced_item Enhanced source item.
         * @param array  $content_source Raw content source.
         * @param string $url           Page URL.
         * @return array{keyword:string,source:string}
         */
        private function determine_serper_seed_keyword(
            $item,
            $enhanced_item,
            $content_source,
            $url
        ) {
            if ( !empty( $item['primary_keyword'] ) ) {
                return array(
                    'keyword' => $this->sanitize_serper_keyword_candidate( $item['primary_keyword'] ),
                    'source'  => 'item_primary_keyword',
                );
            }
            if ( !empty( $enhanced_item['gsc_metrics']['top_keywords'][0]['keyword'] ) ) {
                return array(
                    'keyword' => $this->sanitize_serper_keyword_candidate( $enhanced_item['gsc_metrics']['top_keywords'][0]['keyword'] ),
                    'source'  => 'gsc_keyword',
                );
            }
            if ( !empty( $enhanced_item['gsc_metrics']['top_keywords'][0]['query'] ) ) {
                return array(
                    'keyword' => $this->sanitize_serper_keyword_candidate( $enhanced_item['gsc_metrics']['top_keywords'][0]['query'] ),
                    'source'  => 'gsc_query',
                );
            }
            $content_keyword = $this->derive_serper_keyword_from_content( $content_source );
            if ( !empty( $content_keyword['keyword'] ) ) {
                return $content_keyword;
            }
            $url_keyword = $this->derive_serper_keyword_from_url( $url );
            if ( !empty( $url_keyword ) ) {
                return array(
                    'keyword' => $url_keyword,
                    'source'  => 'url_slug',
                );
            }
            return array(
                'keyword' => '',
                'source'  => 'none',
            );
        }

        /**
         * Derive a short Serper keyword seed from scraped content.
         *
         * @param array $content_source
         * @return array{keyword:string,source:string}
         */
        private function derive_serper_keyword_from_content( $content_source ) {
            if ( !is_array( $content_source ) || empty( $content_source ) ) {
                return array(
                    'keyword' => '',
                    'source'  => 'none',
                );
            }
            $h1_keyword = $this->sanitize_serper_keyword_candidate( $content_source['h1_tags'][0]['text'] ?? '' );
            if ( !empty( $h1_keyword ) ) {
                return array(
                    'keyword' => $h1_keyword,
                    'source'  => 'content_h1',
                );
            }
            $title = trim( (string) ($content_source['title']['text'] ?? '') );
            if ( '' !== $title ) {
                $title_parts = preg_split( '/\\s(?:\\||-|—|:)\\s/u', $title );
                $title_seed = $this->sanitize_serper_keyword_candidate( $title_parts[0] ?? $title );
                if ( !empty( $title_seed ) ) {
                    return array(
                        'keyword' => $title_seed,
                        'source'  => 'content_title',
                    );
                }
            }
            return array(
                'keyword' => '',
                'source'  => 'none',
            );
        }

        /**
         * Derive a basic keyword seed from the URL slug.
         *
         * @param string $url
         * @return string
         */
        private function derive_serper_keyword_from_url( $url ) {
            $path = wp_parse_url( (string) $url, PHP_URL_PATH );
            if ( empty( $path ) || '/' === $path ) {
                return '';
            }
            $segments = array_reverse( array_filter( explode( '/', trim( $path, '/' ) ) ) );
            foreach ( $segments as $segment ) {
                $segment = rawurldecode( (string) $segment );
                $segment = preg_replace( '/[-_]+/', ' ', $segment );
                $segment = preg_replace( '/\\s+/', ' ', trim( (string) $segment ) );
                $segment = $this->sanitize_serper_keyword_candidate( $segment );
                if ( !empty( $segment ) ) {
                    return $segment;
                }
            }
            return '';
        }

        /**
         * Sanitize a short keyword candidate intended for Serper queries.
         *
         * @param mixed $value
         * @return string
         */
        private function sanitize_serper_keyword_candidate( $value ) {
            $value = $this->sanitize_ai_text( $value, 80 );
            if ( '' === $value ) {
                return '';
            }
            $value = preg_replace( '/\\s+/', ' ', trim( $value ) );
            if ( '' === $value || !preg_match( '/[a-z0-9]/i', $value ) ) {
                return '';
            }
            $words = preg_split( '/\\s+/', $value );
            if ( is_array( $words ) && count( $words ) > 8 ) {
                $value = implode( ' ', array_slice( $words, 0, 8 ) );
            }
            return trim( $value );
        }

        /**
         * Build the normalized content analysis bundle used by AI prompts.
         *
         * @param array $content Raw content scraper output.
         * @return array
         */
        private function build_content_analysis_from_source( $content ) {
            if ( !is_array( $content ) || empty( $content ) ) {
                return array();
            }
            $analysis = array(
                'title_length'            => $content['title']['length'] ?? 0,
                'meta_description_length' => $content['meta_description']['length'] ?? 0,
                'word_count'              => $content['word_count'] ?? 0,
                'h1_count'                => count( $content['h1_tags'] ?? array() ),
                'h2_count'                => count( $content['h2_tags'] ?? array() ),
                'internal_links'          => $content['internal_links'] ?? 0,
                'images_without_alt'      => $content['images_without_alt'] ?? 0,
                'has_forms'               => ($content['has_forms']['count'] ?? 0) > 0,
                'cta_count'               => $content['has_cta_buttons']['count'] ?? 0,
                'content_gaps'            => $content['content_gaps'] ?? array(),
                'reading_level'           => $content['reading_level']['level'] ?? 'unknown',
            );
            if ( isset( $content['semantic_analysis'] ) ) {
                $analysis['semantic_analysis'] = $this->extract_semantic_insights( $content );
            }
            return $analysis;
        }

        /**
         * Process a batch of pages with OpenAI (enhanced)
         *
         * NOTE: This method name changed from process_batch() to process_ai_batch()
         * to avoid confusion/collisions with CPA_Batch_Processor::process_batch().
         *
         * @param array  $batch      Array of page data to process.
         * @param string $openai_key OpenAI API key for authentication.
         * @param bool   $is_retry   Whether this is a retry attempt.
         * @return array Array of processed results.
         * @throws Exception When API request fails.
         */
        private function process_ai_batch( $batch, $openai_key, $is_retry = false ) {
            // Detect content types in the batch for contextual prompting.
            $content_types = array();
            foreach ( $batch as $item ) {
                if ( isset( $item['content_analysis'] ) ) {
                    $content_type = $this->detect_content_type( $item['content_analysis'] );
                    $content_types[$content_type] = true;
                }
            }
            // Generate contextual prompt based on detected content types.
            if ( !empty( $content_types ) ) {
                $system_prompt = $this->generate_contextual_prompt( $batch, $is_retry );
            } else {
                // Fallback to enhanced system prompt if no content analysis available.
                $system_prompt = $this->get_enhanced_system_prompt( $is_retry );
            }
            $messages = array(array(
                'role'    => 'system',
                'content' => $system_prompt,
            ), array(
                'role'    => 'user',
                'content' => wp_json_encode( $batch ),
            ));
            $request_options = array(
                'max_tokens'      => 4096,
                'temperature'     => 0.3,
                'timeout'         => 90,
                'response_format' => 'json_object',
            );
            try {
                $content = $this->cpanalyzer_request_chat_completion( $messages, $request_options );
            } catch ( Exception $e ) {
                // Content-heavy pages can produce a request LARGER than a
                // provider's entire per-minute token budget (Groq free tier:
                // 8k TPM) or context window — waiting and retrying can never
                // fix that. Shrink the payload once and resend; a degraded
                // recommendation beats none at all.
                if ( !$this->is_payload_too_large_error( $e->getMessage() ) ) {
                    throw $e;
                }
                $compressed = array_map( array($this, 'compress_ai_item'), $batch );
                $messages[1]['content'] = wp_json_encode( $compressed );
                // If this one fails too, let it propagate normally.
                $content = $this->cpanalyzer_request_chat_completion( $messages, $request_options );
            }
            $actions = json_decode( $content, true );
            // Normalize object-shaped responses to the expected list:
            // a single action object becomes a one-element batch, and a
            // wrapper object (json_object mode often returns e.g.
            // {"results": [...]}) is unwrapped to its first list value.
            if ( is_array( $actions ) && !empty( $actions ) && !isset( $actions[0] ) ) {
                if ( isset( $actions['url'] ) ) {
                    $actions = array($actions);
                } else {
                    foreach ( $actions as $wrapped_value ) {
                        if ( is_array( $wrapped_value ) && isset( $wrapped_value[0] ) ) {
                            $actions = $wrapped_value;
                            break;
                        }
                    }
                }
            }
            if ( !is_array( $actions ) ) {
                // JSON decode failed - attempting recovery
                // Try to extract valid JSON if response contains extra text
                if ( preg_match( '/\\[.*\\]/s', $content, $matches ) ) {
                    $actions = json_decode( $matches[0], true );
                    if ( is_array( $actions ) ) {
                        // Successfully extracted JSON from wrapped response
                    } else {
                        throw new Exception('Failed to parse OpenAI response: ' . esc_html( json_last_error_msg() ));
                    }
                } else {
                    throw new Exception('Failed to parse OpenAI response: Invalid JSON format');
                }
            }
            return $this->normalize_ai_actions( $actions, $batch );
        }

        /**
         * True when an AI error means the REQUEST ITSELF was too big — for a
         * per-minute token budget or a context window — rather than a rate or
         * quota failure. These can only be fixed by shrinking the payload.
         *
         * Note: 429 rate limits never reach this check — the transport
         * replaces their message with a fixed friendly string, so "tokens per
         * minute" wording from quota errors cannot false-positive here.
         *
         * @param string $message Aggregated transport error message.
         * @return bool
         */
        private function is_payload_too_large_error( $message ) {
            $message = strtolower( (string) $message );
            $signatures = array(
                'request too large',
                // Groq 413
                'reduce your message size',
                // Groq 413
                'context_length_exceeded',
                // OpenAI error code
                'maximum context length',
                // OpenAI message
                'maximum number of tokens',
            );
            foreach ( $signatures as $signature ) {
                if ( false !== strpos( $message, $signature ) ) {
                    return true;
                }
            }
            return false;
        }

        /**
         * Shrink one payload item for the too-large retry: drop the heaviest
         * optional context and trim the body while keeping every metric and
         * the decay diagnosis — the parts recommendations must be grounded in.
         *
         * @param array $item Enhanced payload item.
         * @return array
         */
        private function compress_ai_item( $item ) {
            if ( !is_array( $item ) ) {
                return $item;
            }
            // Heaviest optional blocks: competitor page excerpts and Serper
            // expansions. Recommendations degrade gracefully without them.
            unset($item['competitor_context'], $item['serper_signals']);
            if ( isset( $item['gsc_metrics']['top_keywords'] ) && is_array( $item['gsc_metrics']['top_keywords'] ) ) {
                $item['gsc_metrics']['top_keywords'] = array_slice( $item['gsc_metrics']['top_keywords'], 0, 5 );
            }
            if ( isset( $item['cannibalization'] ) && is_array( $item['cannibalization'] ) ) {
                $item['cannibalization'] = array_slice( $item['cannibalization'], 0, 1 );
            }
            if ( isset( $item['content_source'] ) && is_array( $item['content_source'] ) ) {
                if ( isset( $item['content_source']['body_text'] ) && is_string( $item['content_source']['body_text'] ) ) {
                    $item['content_source']['body_text'] = mb_substr( $item['content_source']['body_text'], 0, 1500 );
                }
                unset(
                    $item['content_source']['semantic_analysis'],
                    $item['content_source']['layout_structure'],
                    $item['content_source']['og_data'],
                    $item['content_source']['meta_keywords']
                );
                foreach ( array('h2_tags', 'h3_tags') as $heading_key ) {
                    if ( isset( $item['content_source'][$heading_key] ) && is_array( $item['content_source'][$heading_key] ) ) {
                        $item['content_source'][$heading_key] = array_slice( $item['content_source'][$heading_key], 0, 10 );
                    }
                }
                if ( isset( $item['content_source']['images_missing_alt_list'] ) && is_array( $item['content_source']['images_missing_alt_list'] ) ) {
                    $item['content_source']['images_missing_alt_list'] = array_slice( $item['content_source']['images_missing_alt_list'], 0, 5 );
                }
            }
            return $item;
        }

        /**
         * Normalize AI responses into a stable schema for storage and rendering.
         *
         * @param array $actions Raw actions returned by the model.
         * @param array $batch   Source batch used to derive fallbacks.
         * @return array
         */
        private function normalize_ai_actions( $actions, $batch ) {
            if ( !is_array( $actions ) ) {
                $actions = array();
            }
            if ( array_keys( $actions ) !== range( 0, count( $actions ) - 1 ) ) {
                $actions = array($actions);
            }
            $normalized = array();
            $used_keys = array();
            foreach ( $batch as $index => $source_item ) {
                $matched_action = array();
                $source_url = $source_item['url'] ?? '';
                foreach ( $actions as $action_index => $candidate ) {
                    if ( isset( $used_keys[$action_index] ) ) {
                        continue;
                    }
                    if ( !empty( $source_url ) && isset( $candidate['url'] ) && $candidate['url'] === $source_url ) {
                        $matched_action = $candidate;
                        $used_keys[$action_index] = true;
                        break;
                    }
                }
                if ( empty( $matched_action ) && isset( $actions[$index] ) && !isset( $used_keys[$index] ) ) {
                    $matched_action = $actions[$index];
                    $used_keys[$index] = true;
                }
                $normalized[] = $this->normalize_ai_action( $matched_action, $source_item );
            }
            return $normalized;
        }

        /**
         * Normalize one AI action record.
         *
         * @param array $action      Raw model output.
         * @param array $source_item Source input item.
         * @return array
         */
        private function normalize_ai_action( $action, $source_item ) {
            $keywords = $this->build_ranked_keyword_suggestions( $action, $source_item );
            $primary_keyword = $this->sanitize_ai_text( $action['primary_keyword'] ?? '', 120 );
            if ( empty( $primary_keyword ) && !empty( $keywords[0] ) ) {
                $primary_keyword = $keywords[0];
            }
            if ( !empty( $primary_keyword ) && !empty( $keywords[0] ) ) {
                $normalized_primary = $this->normalize_keyword_candidate( $primary_keyword );
                if ( '' !== $normalized_primary && !in_array( $normalized_primary, $keywords, true ) ) {
                    $primary_keyword = $keywords[0];
                }
            }
            if ( empty( $primary_keyword ) ) {
                $gsc_keywords = $source_item['gsc_metrics']['top_keywords'] ?? array();
                if ( !empty( $gsc_keywords[0]['keyword'] ) ) {
                    $primary_keyword = $this->sanitize_ai_text( $gsc_keywords[0]['keyword'], 120 );
                } elseif ( !empty( $gsc_keywords[0]['query'] ) ) {
                    $primary_keyword = $this->sanitize_ai_text( $gsc_keywords[0]['query'], 120 );
                }
            }
            $content_priority = strtolower( (string) ($action['content_priority'] ?? $action['priority'] ?? '') );
            if ( !in_array( $content_priority, array('high', 'medium', 'low'), true ) ) {
                $content_priority = 'medium';
            }
            $normalized = array(
                'url'                       => $source_item['url'] ?? $action['url'] ?? '',
                'flag'                      => $source_item['flag'] ?? $action['flag'] ?? '',
                'action'                    => $this->sanitize_ai_text( $action['action'] ?? '', 140 ),
                'issue_summary'             => $this->sanitize_ai_text( $action['issue_summary'] ?? '', 500 ),
                'why_now'                   => $this->sanitize_ai_text( $action['why_now'] ?? '', 500 ),
                'seo_recommendation'        => $this->sanitize_ai_text( $action['seo_recommendation'] ?? '', 1200 ),
                'aeo_recommendation'        => $this->sanitize_ai_text( $action['aeo_recommendation'] ?? '', 1200 ),
                'geo_recommendation'        => $this->sanitize_ai_text( $action['geo_recommendation'] ?? '', 1200 ),
                'model_used'                => sanitize_text_field( $this->last_model_used ),
                'engagement_recommendation' => $this->sanitize_ai_text( $action['engagement_recommendation'] ?? '', 1200 ),
                'measurement_plan'          => $this->sanitize_ai_text( $action['measurement_plan'] ?? '', 800 ),
                'primary_keyword'           => $primary_keyword,
                'suggested_keywords'        => $keywords,
                'content_priority'          => $content_priority,
                'technical_issues'          => $this->sanitize_ai_string_array( $action['technical_issues'] ?? array(), 4, 120 ),
                'action_type'               => $this->sanitize_ai_text( $action['action_type'] ?? '', 40 ),
                'expected_ctr_delta'        => $this->sanitize_ai_text( $action['expected_ctr_delta'] ?? '', 40 ),
                'expected_conv_uplift'      => $this->sanitize_ai_text( $action['expected_conv_uplift'] ?? '', 40 ),
                'opportunity_score'         => ( isset( $action['opportunity_score'] ) ? intval( $action['opportunity_score'] ) : 0 ),
                'confidence'                => ( isset( $action['confidence'] ) ? floatval( $action['confidence'] ) : 0 ),
                'effort_hours'              => ( isset( $action['effort_hours'] ) ? floatval( $action['effort_hours'] ) : 0 ),
                'timestamp'                 => time(),
            );
            if ( empty( $normalized['action'] ) || strtolower( $normalized['action'] ) === 'no suggestion' ) {
                $normalized['action'] = $this->build_fallback_action_title( $source_item, $primary_keyword );
            }
            if ( empty( $normalized['issue_summary'] ) ) {
                $normalized['issue_summary'] = $this->build_fallback_issue_summary( $source_item, $primary_keyword );
            }
            if ( empty( $normalized['why_now'] ) ) {
                $normalized['why_now'] = $this->build_fallback_why_now( $source_item );
            }
            if ( empty( $normalized['seo_recommendation'] ) ) {
                $normalized['seo_recommendation'] = $this->build_fallback_channel_recommendation( $source_item, 'seo', $primary_keyword );
            }
            if ( empty( $normalized['aeo_recommendation'] ) ) {
                $normalized['aeo_recommendation'] = $this->build_fallback_channel_recommendation( $source_item, 'aeo', $primary_keyword );
            }
            if ( empty( $normalized['geo_recommendation'] ) ) {
                $normalized['geo_recommendation'] = $this->build_fallback_channel_recommendation( $source_item, 'geo', $primary_keyword );
            }
            if ( empty( $normalized['engagement_recommendation'] ) ) {
                $normalized['engagement_recommendation'] = $this->build_fallback_channel_recommendation( $source_item, 'engagement', $primary_keyword );
            }
            if ( empty( $normalized['measurement_plan'] ) ) {
                $normalized['measurement_plan'] = 'Measure clicks, CTR, engagement rate, and form fills for 14-28 days against the previous period.';
            }
            return $normalized;
        }

        /**
         * Build a ranked keyword list from AI, GSC, and Serper signals.
         *
         * @param array $action      Raw AI action output.
         * @param array $source_item Source payload item.
         * @return array
         */
        private function build_ranked_keyword_suggestions( $action, $source_item ) {
            $ai_keywords = $this->normalize_keyword_collection( $action['suggested_keywords'] ?? $action['keywords'] ?? array(), 20, 120 );
            $gsc_keywords = array();
            foreach ( (array) ($source_item['gsc_metrics']['top_keywords'] ?? array()) as $keyword_row ) {
                if ( !is_array( $keyword_row ) ) {
                    continue;
                }
                $gsc_keyword = $keyword_row['keyword'] ?? $keyword_row['query'] ?? '';
                if ( '' !== $gsc_keyword ) {
                    $gsc_keywords[] = $gsc_keyword;
                }
            }
            $gsc_keywords = $this->normalize_keyword_collection( $gsc_keywords, 20, 120 );
            $serper_related = $this->normalize_keyword_collection( $source_item['serper_signals']['related_keywords'] ?? array(), 20, 120 );
            $serper_paa = $this->normalize_keyword_collection( $source_item['serper_signals']['paa'] ?? array(), 20, 140 );
            $serper_related = $this->filter_keyword_candidates_by_relevance( $serper_related, $source_item );
            $serper_paa = $this->filter_keyword_candidates_by_relevance( $serper_paa, $source_item );
            $ai_keywords = $this->filter_keyword_candidates_by_relevance( $ai_keywords, $source_item );
            $scored_keywords = array();
            $keyword_order = array();
            $next_order = 0;
            $sources = array(
                array(
                    'values' => $gsc_keywords,
                    'weight' => 3,
                ),
                array(
                    'values' => $serper_related,
                    'weight' => 2,
                ),
                array(
                    'values' => $serper_paa,
                    'weight' => 2,
                ),
                array(
                    'values' => $ai_keywords,
                    'weight' => 1,
                )
            );
            foreach ( $sources as $source ) {
                foreach ( $source['values'] as $keyword ) {
                    if ( !isset( $scored_keywords[$keyword] ) ) {
                        $scored_keywords[$keyword] = 0;
                        $keyword_order[$keyword] = $next_order;
                        $next_order++;
                    }
                    $scored_keywords[$keyword] += $source['weight'];
                }
            }
            if ( empty( $scored_keywords ) ) {
                return array();
            }
            uksort( $scored_keywords, function ( $left, $right ) use($scored_keywords, $keyword_order) {
                if ( $scored_keywords[$left] === $scored_keywords[$right] ) {
                    return $keyword_order[$left] <=> $keyword_order[$right];
                }
                return $scored_keywords[$right] <=> $scored_keywords[$left];
            } );
            return array_slice( array_keys( $scored_keywords ), 0, 8 );
        }

        /**
         * Filter non-GSC keyword candidates against page context to prevent ambiguous drift.
         *
         * @param array $keywords
         * @param array $source_item
         * @return array
         */
        private function filter_keyword_candidates_by_relevance( $keywords, $source_item ) {
            $filtered = array();
            foreach ( (array) $keywords as $keyword ) {
                if ( $this->is_keyword_candidate_relevant( $keyword, $source_item ) ) {
                    $filtered[] = $keyword;
                }
            }
            return array_values( array_unique( $filtered ) );
        }

        /**
         * Determine whether a non-GSC keyword candidate is relevant to the page's actual context.
         *
         * @param string $keyword
         * @param array  $source_item
         * @return bool
         */
        private function is_keyword_candidate_relevant( $keyword, $source_item ) {
            $keyword_tokens = $this->extract_keyword_tokens( $keyword );
            if ( empty( $keyword_tokens ) ) {
                return false;
            }
            $context_tokens = $this->build_keyword_context_tokens( $source_item );
            if ( empty( $context_tokens ) ) {
                return false;
            }
            $overlap_tokens = array_values( array_intersect( $keyword_tokens, $context_tokens ) );
            $overlap_count = count( $overlap_tokens );
            $total_tokens = count( $keyword_tokens );
            if ( 0 === $overlap_count ) {
                return false;
            }
            $generic_noise_tokens = array(
                'meaning',
                'meanings',
                'definition',
                'definitions',
                'quote',
                'quotes',
                'movie',
                'movies',
                'wikipedia',
                'wiki',
                'pronunciation',
                'synonym',
                'synonyms',
                'values',
                'virtues',
                'five',
                'seven',
                'history',
                'origin',
                'etymology'
            );
            foreach ( $keyword_tokens as $token ) {
                if ( in_array( $token, $generic_noise_tokens, true ) && !in_array( $token, $context_tokens, true ) ) {
                    return false;
                }
            }
            if ( $total_tokens >= 3 && $overlap_count < 2 ) {
                return false;
            }
            return true;
        }

        /**
         * Build a token set describing the page's real topic from stored context.
         *
         * @param array $source_item
         * @return array
         */
        private function build_keyword_context_tokens( $source_item ) {
            $context_values = array();
            $content_source = ( is_array( $source_item['content_source'] ?? null ) ? $source_item['content_source'] : array() );
            $content_analysis = ( is_array( $source_item['content_analysis'] ?? null ) ? $source_item['content_analysis'] : array() );
            $context_values[] = $source_item['url'] ?? '';
            $context_values[] = $content_source['title']['text'] ?? '';
            $context_values[] = $content_source['meta_description']['text'] ?? '';
            foreach ( array('h1_tags', 'h2_tags', 'h3_tags') as $heading_level ) {
                foreach ( (array) ($content_source[$heading_level] ?? array()) as $heading ) {
                    if ( is_array( $heading ) ) {
                        $context_values[] = $heading['text'] ?? '';
                    } else {
                        $context_values[] = $heading;
                    }
                }
            }
            $semantic = ( is_array( $content_analysis['semantic_analysis'] ?? null ) ? $content_analysis['semantic_analysis'] : array() );
            foreach ( (array) ($semantic['topic_keywords'] ?? array()) as $topic_keyword ) {
                $context_values[] = $topic_keyword;
            }
            foreach ( (array) ($semantic['content_themes'] ?? array()) as $content_theme ) {
                $context_values[] = $content_theme;
            }
            $tokens = array();
            foreach ( $context_values as $context_value ) {
                foreach ( $this->extract_keyword_tokens( $context_value ) as $token ) {
                    $tokens[$token] = $token;
                }
            }
            return array_values( $tokens );
        }

        /**
         * Extract meaningful normalized tokens from a keyword or context string.
         *
         * @param mixed $value
         * @return array
         */
        private function extract_keyword_tokens( $value ) {
            $value = $this->sanitize_ai_text( $value, 200 );
            if ( '' === $value ) {
                return array();
            }
            if ( function_exists( 'mb_strtolower' ) ) {
                $value = mb_strtolower( $value );
            } else {
                $value = strtolower( $value );
            }
            $value = preg_replace( '/[^a-z0-9]+/i', ' ', $value );
            $parts = preg_split( '/\\s+/', trim( (string) $value ) );
            if ( !is_array( $parts ) ) {
                return array();
            }
            $stopwords = array(
                'a',
                'an',
                'and',
                'are',
                'as',
                'at',
                'be',
                'by',
                'for',
                'from',
                'how',
                'in',
                'is',
                'it',
                'of',
                'on',
                'or',
                'that',
                'the',
                'this',
                'to',
                'up',
                'what',
                'when',
                'where',
                'which',
                'who',
                'why',
                'with',
                'your'
            );
            $tokens = array();
            foreach ( $parts as $part ) {
                $part = trim( (string) $part );
                if ( '' === $part ) {
                    continue;
                }
                if ( strlen( $part ) <= 2 ) {
                    continue;
                }
                if ( in_array( $part, $stopwords, true ) ) {
                    continue;
                }
                $tokens[$part] = $part;
            }
            return array_values( $tokens );
        }

        private function sanitize_ai_text( $value, $max_length = 220 ) {
            if ( is_array( $value ) ) {
                $value = implode( '; ', array_filter( array_map( 'strval', $value ) ) );
            }
            $value = wp_strip_all_tags( (string) $value );
            $value = preg_replace( '/\\s+/', ' ', $value );
            $value = trim( $value );
            if ( '' === $value ) {
                return '';
            }
            if ( mb_strlen( $value ) <= $max_length ) {
                return $value;
            }
            // Backstop only — cut at a word boundary and say so, never
            // mid-word. The per-field limits are sized so well-formed
            // responses are stored whole.
            $cut = mb_substr( $value, 0, $max_length );
            $space = mb_strrpos( $cut, ' ' );
            if ( false !== $space && $space > $max_length * 0.6 ) {
                $cut = mb_substr( $cut, 0, $space );
            }
            return rtrim( $cut, " \t.,;:—-" ) . '…';
        }

        /**
         * Sanitize an AI-generated list of strings.
         *
         * @param mixed $value      Raw value.
         * @param int   $limit      Maximum items to keep.
         * @param int   $max_length Maximum item length.
         * @return array
         */
        private function sanitize_ai_string_array( $value, $limit = 5, $max_length = 100 ) {
            if ( is_string( $value ) ) {
                $value = preg_split( '/[,;\\n]+/', $value );
            }
            if ( !is_array( $value ) ) {
                return array();
            }
            $items = array();
            foreach ( $value as $item ) {
                $item = $this->sanitize_ai_text( $item, $max_length );
                if ( '' !== $item ) {
                    $items[] = $item;
                }
                if ( count( $items ) >= $limit ) {
                    break;
                }
            }
            return array_values( array_unique( $items ) );
        }

        /**
         * Normalize a keyword candidate for scoring and display.
         *
         * @param mixed $keyword    Raw keyword candidate.
         * @param int   $max_length Maximum keyword length.
         * @return string
         */
        private function normalize_keyword_candidate( $keyword, $max_length = 120 ) {
            $keyword = $this->sanitize_ai_text( $keyword, $max_length );
            if ( '' === $keyword ) {
                return '';
            }
            $keyword = preg_replace( '/\\s+/', ' ', trim( $keyword ) );
            if ( function_exists( 'mb_strtolower' ) ) {
                $keyword = mb_strtolower( $keyword );
            } else {
                $keyword = strtolower( $keyword );
            }
            return trim( $keyword );
        }

        /**
         * Normalize and deduplicate a keyword collection.
         *
         * @param mixed $keywords    Raw keyword collection.
         * @param int   $limit       Maximum items to keep.
         * @param int   $max_length  Maximum keyword length.
         * @return array
         */
        private function normalize_keyword_collection( $keywords, $limit = 20, $max_length = 120 ) {
            if ( is_string( $keywords ) ) {
                $keywords = preg_split( '/[,;\\n]+/', $keywords );
            }
            if ( !is_array( $keywords ) ) {
                return array();
            }
            $normalized = array();
            foreach ( $keywords as $keyword ) {
                $keyword = $this->normalize_keyword_candidate( $keyword, $max_length );
                if ( '' === $keyword ) {
                    continue;
                }
                $normalized[$keyword] = $keyword;
                if ( count( $normalized ) >= $limit ) {
                    break;
                }
            }
            return array_values( $normalized );
        }

        /**
         * Build a data-driven fallback headline.
         *
         * @param array  $source_item     Source item.
         * @param string $primary_keyword Primary keyword.
         * @return string
         */
        private function build_fallback_action_title( $source_item, $primary_keyword ) {
            $flag = $source_item['flag'] ?? '';
            switch ( $flag ) {
                case 'decay':
                    return 'Refresh the page to recover lost search demand';
                case 'conversion':
                    return 'Tighten the CTA path and remove conversion friction';
                case 'performance':
                    return 'Fix speed bottlenecks that are suppressing engagement';
                case 'seo':
                default:
                    if ( !empty( $primary_keyword ) ) {
                        return 'Rework the page around ' . $primary_keyword . ' to win more qualified clicks';
                    }
                    return 'Strengthen the page for rankings, answer engines, and engagement';
            }
        }

        /**
         * Build a data-driven fallback diagnosis summary.
         *
         * @param array  $source_item     Source item.
         * @param string $primary_keyword Primary keyword.
         * @return string
         */
        private function build_fallback_issue_summary( $source_item, $primary_keyword ) {
            $ga_data = $source_item['ga_metrics'] ?? array();
            $gsc_data = $source_item['gsc_metrics'] ?? array();
            $content_data = $source_item['content_analysis'] ?? array();
            $flag = $source_item['flag'] ?? '';
            $views = intval( $ga_data['views'] ?? 0 );
            $views_prev = intval( $ga_data['views_previous'] ?? $ga_data['views_prev'] ?? 0 );
            $engagement = ( isset( $ga_data['engagement'] ) ? round( floatval( $ga_data['engagement'] ) * 100, 1 ) : 0 );
            $form_fills = intval( $ga_data['form_fills'] ?? 0 );
            $impressions = intval( $gsc_data['impressions'] ?? 0 );
            $ctr = ( isset( $gsc_data['ctr'] ) ? round( floatval( $gsc_data['ctr'] ), 1 ) : 0 );
            $position = ( isset( $gsc_data['position'] ) ? round( floatval( $gsc_data['position'] ), 1 ) : 0 );
            $word_count = intval( $content_data['word_count'] ?? 0 );
            $cta_count = intval( $content_data['cta_count'] ?? 0 );
            switch ( $flag ) {
                case 'decay':
                    if ( $views_prev > 0 ) {
                        $drop_pct = round( ($views_prev - $views) / max( 1, $views_prev ) * 100, 1 );
                        return sprintf(
                            'Traffic has slipped from %d to %d views (%s%% down), which usually means the page is losing freshness or intent fit.',
                            $views_prev,
                            $views,
                            $drop_pct
                        );
                    }
                    return 'The page is showing signs of traffic decay and likely needs a relevance and freshness refresh.';
                case 'conversion':
                    return sprintf(
                        'The page attracts %d views with %s%% engagement, but only %d form fills and %d visible CTA points suggest weak conversion intent capture.',
                        $views,
                        $engagement,
                        $form_fills,
                        $cta_count
                    );
                case 'performance':
                    return 'Core Web Vitals are under target, which can reduce both rankings and on-page engagement for high-intent visits.';
                case 'seo':
                default:
                    if ( $impressions > 0 ) {
                        return sprintf(
                            'The page is visible for %s with %d impressions, but %s%% CTR at avg. position %s suggests weak SERP messaging or answer coverage.',
                            ( $primary_keyword ? '"' . $primary_keyword . '"' : 'search demand' ),
                            $impressions,
                            $ctr,
                            $position
                        );
                    }
                    if ( $word_count > 0 ) {
                        return sprintf( 'The page only has %d words of indexable content, which limits topical depth and answer coverage.', $word_count );
                    }
                    return 'The content does not yet communicate enough relevance, depth, or answer-first value to compete confidently.';
            }
        }

        /**
         * Build a fallback "why now" statement.
         *
         * @param array $source_item Source item.
         * @return string
         */
        private function build_fallback_why_now( $source_item ) {
            $flag = $source_item['flag'] ?? '';
            $gsc_data = $source_item['gsc_metrics'] ?? array();
            $ga_data = $source_item['ga_metrics'] ?? array();
            switch ( $flag ) {
                case 'decay':
                    return 'Delaying the refresh usually compounds the loss because fresher competitors and AI answers absorb the demand first.';
                case 'conversion':
                    return 'This is already attracting traffic, so improving clarity and CTA placement can convert existing visits faster than chasing new traffic.';
                case 'performance':
                    return 'Poor speed is affecting both search satisfaction signals and the user experience for visitors who already found the page.';
                case 'seo':
                default:
                    if ( intval( $gsc_data['impressions'] ?? 0 ) >= 1000 ) {
                        return 'The page already has search visibility, so clearer SERP messaging and stronger answer coverage can unlock faster gains than creating a net-new page.';
                    }
                    if ( intval( $ga_data['views'] ?? 0 ) > 250 ) {
                        return 'The page already has an audience, which makes this a high-leverage refresh candidate for both rankings and engagement.';
                    }
                    return 'Improving answer clarity and structure now increases the chance of winning both traditional rankings and AI-generated citations.';
            }
        }

        /**
         * Build fallback channel recommendations when the model omits them.
         *
         * @param array  $source_item     Source item.
         * @param string $channel         Recommendation channel.
         * @param string $primary_keyword Primary keyword.
         * @return string
         */
        private function build_fallback_channel_recommendation( $source_item, $channel, $primary_keyword ) {
            $content_data = $source_item['content_analysis'] ?? array();
            $word_count = intval( $content_data['word_count'] ?? 0 );
            $cta_count = intval( $content_data['cta_count'] ?? 0 );
            $flag = $source_item['flag'] ?? '';
            switch ( $channel ) {
                case 'seo':
                    if ( $word_count > 0 && $word_count < 500 ) {
                        return 'Expand the page with two intent-matched H2 sections, supporting examples, and tighter keyword alignment around the core query.';
                    }
                    return 'Rewrite the title/H1-to-body narrative so the primary query is answered faster, then add one comparison or proof section that competitors are likely covering.';
                case 'aeo':
                    return ( !empty( $primary_keyword ) ? 'Add an answer-first summary under the H1 for "' . $primary_keyword . '" plus 3-5 concise FAQs that can be cited directly by answer engines.' : 'Add an answer-first summary under the H1 plus short FAQ blocks that resolve the most likely follow-up questions immediately.' );
                case 'geo':
                    return 'Create scannable, citation-friendly blocks: definitions, step lists, proof points, and named entities so LLMs can quote the page with confidence.';
                case 'engagement':
                default:
                    if ( 'conversion' === $flag || $cta_count < 2 ) {
                        return 'Move a stronger CTA higher on the page, clarify the value proposition above the fold, and reduce form friction or decision friction near the conversion point.';
                    }
                    return 'Improve engagement with clearer section transitions, stronger internal links, and one proof-driven block that gives visitors a reason to continue.';
            }
        }

        /**
         * Get enhanced system prompt for OpenAI with content analysis
         *
         * @param bool $is_retry
         * @return string
         */
        private function get_enhanced_system_prompt( $is_retry = false ) {
            return <<<'PROMPT'
You are an elite Content Decay Strategist and GEO (Generative Engine Optimization) specialist embedded inside a WordPress analytics plugin. You receive structured per-URL payloads assembled from GA4, Google Search Console, on-page content scraping, optional PageSpeed Insights data, and optional competitor SERP intelligence. Your job is to turn this data into high-value strategic refresh recommendations that feel like a $500 consulting brief, while staying faithful to the plugin's current data model.

Return a JSON array the EXACT same length and order as the input. For each item, output ONLY this object shape:

{
  "url": string,
  "flag": string,
  "action": string,
  "issue_summary": string,
  "why_now": string,
  "seo_recommendation": string,
  "aeo_recommendation": string,
  "geo_recommendation": string,
  "engagement_recommendation": string,
  "primary_keyword": string,
  "suggested_keywords": string[],
  "content_priority": "high" | "medium" | "low",
  "technical_issues": string[],
  "action_type": string,
  "opportunity_score": number,
  "expected_ctr_delta": string,
  "confidence": number,
  "effort_hours": number,
  "measurement_plan": string
}

RECOMMENDATION FIELD DEFINITIONS (follow these exactly — do not reinterpret):
- seo_recommendation: traditional Google rankings — titles, headings, content depth, internal links, keyword targeting. NOT forms, CTAs, or conversion elements.
- aeo_recommendation: Answer Engine Optimization — winning featured snippets and voice/AI assistant answers: question-phrased headings, concise 40-60 word answer paragraphs, FAQ/HowTo schema.
- geo_recommendation: Generative Engine Optimization — getting this page cited by generative AI (ChatGPT, Perplexity, Google AI Overviews): quotable statistics with sources, clear entity naming, citable standalone claims, visible freshness. This is NOT geographic or local SEO — never suggest city or location targeting here unless the page itself is explicitly about a location.
- engagement_recommendation: on-page engagement and conversion — CTAs, forms, readability, media, page experience.

STRICT OUTPUT RULES:
- Reply with a valid JSON array ONLY.
- No markdown fences, no intro, no explanation outside the array.
- No extra keys beyond the schema above. The plugin normalizer drops unknown fields.
- Preserve the input `url` exactly.
- Preserve the input `flag` exactly as provided unless it is missing.
- Do NOT invent a new flag taxonomy.

PLUGIN FLAG COMPATIBILITY:
The plugin sets flags upstream before this prompt runs. Valid values commonly include:
- "decay"
- "seo"
- "conversion"
- "performance"
- "none"

Echo the input flag back unchanged whenever it is present.
If the input flag is missing or null, infer the closest compatible value from the list above using payload signals.
Do NOT rename flags to alternatives like "seo_issue", "conversion_gap", "performance_issue", or "healthy".

PAYLOAD FIELD USAGE:
Each item may contain these fields. Use every meaningful signal that is actually present. Do not reference fields that are absent.

- `ga_metrics`: views, uniques, engagement, duration, form_fills, views_previous
- `gsc_metrics`: impressions, clicks, ctr, position, impressions_prev, clicks_prev, position_prev, top_keywords[]
- `content_source`: title, meta_description, h1_tags, h2_tags, h3_tags, body_text, word_count,
  internal_links, external_links, has_schema, has_noindex, canonical_url, canonical_mismatch,
  images_without_alt, has_forms, has_cta_buttons, reading_level, layout_structure, content_gaps,
  semantic_analysis, inbound_internal_links, is_orphan, and optionally psi
- `competitor_context`: optional array of competitor pages with headings[] and body_excerpt
- `serper_signals`: optional related_keywords[] and paa[]
- `decay_diagnosis`: optional deterministic classification of a traffic drop, computed by the
  plugin from real period deltas: { type, label, confidence, evidence, fix_hint, recent_edit, trend_note }
- `cannibalization`: optional list of queries where this page splits clicks with other pages
  on the SAME site, each with own_clicks, own_position, and competing[] pages
- `serp_features`: optional live SERP snapshot for the page's top query: answer_box,
  paa_count, knowledge_graph, video_present, non_article_top, own_position, top_domains[]
- `click_trend`: optional 6-period click history summary: declining_periods, peak, latest, vs_peak_pct

DECAY DIAGNOSIS RULES (when `decay_diagnosis` is present, these override generic advice):
The plugin has already classified WHY the page is losing traffic. Match every recommendation to the type — the wrong fix wastes the user's time:
- "ranking_decay": rankings slid. Content refresh IS appropriate: deeper coverage, updated facts, missing subtopics, stronger internal links. If `cannibalization` is non-empty, recommend consolidating the competing pages FIRST — a refresh cannot fix a page competing with its own site.
- "zero_click": the page still ranks and is still seen, but the SERP answers the query before the click. Do NOT recommend a generic rewrite. Recommend: restructuring for citation-readiness, adding value a SERP summary cannot replace (tools, templates, original data, decision frameworks), and stronger meta/title to win the click that remains. If `serp_features.answer_box` is true, state that an answer box was confirmed on the live SERP.
- "demand_decay": fewer people search this topic. NEVER recommend a content refresh to fix this — rewrites cannot create demand. Recommend consolidating into a broader evergreen page, redirecting, or shifting effort to adjacent topics with live demand.
- "non_search": organic search is HEALTHY for this page; the drop came from another channel. State this explicitly and do NOT frame any recommendation as an SEO fix. Point at campaigns, referral, social, or email as the place to investigate.
- "seasonal": the drop matches the same window last year — an EXPECTED pattern, not decay. Do NOT recommend a refresh, consolidation, or redirect to fix the drop. Say plainly that no action is needed for the traffic pattern; only mention genuine on-page improvements if they exist independently of the drop. Set content_priority to "low".
- "technical": impressions collapsed to near zero, or the page carries a noindex tag / foreign canonical. This is an indexing problem — NO content recommendation can help until it is resolved. Lead with the specific technical check (noindex removal, robots.txt, canonical, URL Inspection) and set `action_type` to "Technical_fix".
- "tracking": GA4 stopped recording while search clicks kept arriving — almost certainly a broken GA4 tag, not a traffic loss. Lead with verifying the tag (template change, consent banner, tag manager edit). Never frame this as an SEO or content problem.
- "mixed": several signals moved at once. Present the evidence and recommend a manual SERP review before committing to one fix.
- If `decay_diagnosis.recent_edit` is true: warn that the page was edited inside the drop window — the update itself may have caused the drop. Recommend comparing the previous revision before any further changes.
- Quote the numbers in `decay_diagnosis.evidence` rather than recomputing your own.
- If `serp_features.non_article_top` >= 3, flag possible intent drift: the SERP now rewards a different content format — recommend matching the winning format, not adding paragraphs.
- If `content_source.is_orphan` is true, recommend adding internal links from related high-traffic pages as a priority action.

PERFORMANCE DIAGNOSIS (always required):
Use REAL numbers from `ga_metrics` and `gsc_metrics`. Never use placeholders like [X]% or [metric].
- State exact impressions, clicks, CTR, position, and prior-period trends when available.
- If a page has impressions but weak CTR for its position, say so explicitly with the actual numbers.
- If clicks or views declined materially vs the prior period, state the actual delta.
- If a page has traffic but weak conversion signals, state it explicitly.
- Classify dominant user intent from `gsc_metrics.top_keywords`: informational, transactional, comparative, or navigational.
- Flag intent mismatch explicitly when present.

CONTENT ANALYSIS RULES:
Use `content_source` whenever present. Interpret these real plugin fields directly:
- `word_count`: thin if < 600, adequate 600–1500, comprehensive > 1500
- `h1_tags`, `h2_tags`, `h3_tags`: use heading presence, depth, and structure
- `internal_links`: flag zero or very low internal links on any non-homepage URL
- `external_links`: note weak citation/proof support when content type warrants it
- `has_schema`: recommend Article, FAQ, or HowTo schema when false and the content type warrants it
- `has_forms`: this is structured form data, not a boolean. Use form count/presence as a conversion signal.
- `has_cta_buttons`: this is structured CTA data, not a boolean. Use CTA count/presence as a conversion signal.
- `reading_level`: flag very high reading level for general-audience pages when relevant
- `layout_structure`: use to inform structural recommendations
- `content_gaps`: incorporate directly into recommendations when clearly relevant
- `semantic_analysis`: use to validate page topic, intent, and keyword fit

Do not ignore scraper data. Use it to make recommendations more specific and product-aware.

SPECIFICITY RULES (apply to every recommendation field):
- Cite the page's actual numbers (views, impressions, word count, link counts, scores) whenever they justify the recommendation.
- Prescribe concrete deliverables: word counts, section topics, example titles, specific elements to add — not directions like "optimize content" or "add targeted keywords".
- Reject any sentence that could apply to a different page unchanged; every recommendation must be traceable to THIS page's data.

PAGESPEED / PSI RULES:
If `content_source.psi` is present:
- Reference actual PSI values when materially relevant.
- Use the available primary strategy and nested metrics such as score, LCP, INP, CLS, and major opportunities when present.
- Only add items to `technical_issues` when they materially affect visibility, rendering, or serious page experience.
- Never include in `technical_issues`: alt text, title tag length, meta description length, or image count.
- Use performance friction to inform `engagement_recommendation` when it affects above-the-fold experience or user trust.
- Set `action_type` to "Performance_fix" when poor PSI is the dominant issue.

COMPETITOR GAP ANALYSIS:
If `competitor_context` is non-empty:
- Compare the user page's heading structure and coverage against competitor headings and excerpts.
- Identify SPECIFIC missing sections, content blocks, FAQs, comparison elements, proof blocks, or answer-first content.
- Quote actual competitor headings in `action` or `seo_recommendation` only when they clearly reveal a gap.
- Recommend specific structural additions: new H2s, comparison tables, FAQ blocks, answer-first paragraphs.
When `competitor_context` is empty, analyse internal signals only. Do NOT invent competitor claims.

PRIMARY KEYWORD RULES:
Strict priority order:
1. GSC direct query: use the entry from `gsc_metrics.top_keywords` with the highest clicks; break ties by highest impressions. Branded queries are valid and must NOT be skipped.
2. Derived fallback: only use a fallback keyword when it is clearly supported by page context from title, URL path, and headings, and when it does not drift into an unrelated meaning.
3. If support is weak, ambiguous, or absent, set `primary_keyword` to "".

Do not invent or guess a keyword when support is weak or ambiguous.

SUGGESTED KEYWORDS RULES:
- Source ONLY from: `gsc_metrics.top_keywords`, `serper_signals.related_keywords`, and `serper_signals.paa`.
- Do NOT invent unsupported terms.
- Priority order: real GSC queries first, then Serper related/PAA expansions.
- Maximum 8 keywords.
- Keep them tightly relevant to the actual page intent.

ACTION QUALITY RULES:
- `action`: one short high-value headline, max 90 characters.
- `issue_summary`: 1–2 sentences using real numbers from the payload.
- `why_now`: 1 sentence explaining business or search urgency.
- `seo_recommendation`, `aeo_recommendation`, `geo_recommendation`, `engagement_recommendation`:
  specific enough for an editor or marketer to act on immediately without further research.
  Write 2-4 complete sentences per field — finish every thought; never end mid-sentence.
- Zero tolerance for generic phrases:
  - "improve SEO"
  - "add more content"
  - "optimize for keywords"
  - "enhance user experience"

ACTION TYPE RULES:
Use exactly one of these values and map to the dominant opportunity:
- "CTR_boost"
- "Conversion_boost"
- "Traffic_rescue"
- "Competitor_gap"
- "Technical_fix"
- "Performance_fix"
- "Content_depth"

Suggested interpretation:
- "CTR_boost": impressions are strong and CTR is weak for position
- "Conversion_boost": traffic exists but conversion elements are weak or missing
- "Traffic_rescue": clicks or views dropped materially vs prior period
- "Competitor_gap": competitor_context reveals clear structural/content gaps
- "Technical_fix": a real crawl, indexing, rendering, or major page experience blocker is present
- "Performance_fix": PSI is materially poor and is the dominant issue
- "Content_depth": thin or structurally weak content is the main problem

When multiple types apply, choose the one with the highest user-facing impact.

SCORING RULES:
`opportunity_score`:
- Must be a computed number, not a placeholder.
- For CTR-driven cases, use:
  impressions × max(0, target_ctr − current_ctr)
- Target CTR benchmarks by GSC position:
  - position 1–3 → 0.12
  - position 4–6 → 0.07
  - position 7–10 → 0.03
  - position > 10 → 0.01
- Use `gsc_metrics.position` and `gsc_metrics.ctr` for the calculation.
- If impressions or position are missing, set to 0.

`expected_ctr_delta`:
- Must be a short STRING, not a number.
- Express it as a realistic estimated uplift such as "0.8–2.0%" or "1.2%".
- Base it on the position gap and action type.
- Do not return null, arrays, or long explanations.

`confidence`:
- 0.0 to 1.0.
- Higher when competitor data is present and the diagnosis is clear.
- Lower when key signals are sparse.

`effort_hours`:
- realistic editor/implementation hours end-to-end.

`content_priority`:
- "high" for strong opportunity, urgent decay, clear conversion gap, or major recovery case
- "medium" for meaningful but less urgent opportunities
- "low" for minor improvement opportunities

MEASUREMENT PLAN RULES:
`measurement_plan` must name the exact metric and timeframe.
Good examples:
- "Track CTR and clicks in GSC for 14–28 days against the previous period."
- "Track form fills and conversion rate in GA4 for 2–4 weeks after CTA changes."
- "Monitor PSI score and Core Web Vitals trend over the next 28 days."

Bad examples:
- "Monitor performance."
- "Check results after changes."

FINAL QUALITY GUARDS:
- Use actual payload values. No placeholders anywhere.
- No invented competitor insights when competitor_context is empty.
- No invented keywords not present in GSC or Serper signals.
- No extra fields.
- `opportunity_score` must always be numeric.
- `expected_ctr_delta` must always be a short string.
- Preserve backward compatibility with the schema above.
PROMPT;
        }

        /**
         * Save AI actions to database
         *
         * @param array $new_actions
         * @return void
         */
        private function save_ai_actions( $new_actions ) {
            $existing_actions = get_option( 'cpanalyzer_page_actions', array() );
            $actions_by_url = array();
            // First, add existing non-expired actions
            foreach ( $existing_actions as $action ) {
                if ( !empty( $action['url'] ) ) {
                    $timestamp = $action['timestamp'] ?? 0;
                    if ( !cpanalyzer_is_suggestion_expired( $timestamp ) ) {
                        $actions_by_url[$action['url']] = $action;
                    }
                }
            }
            // Then, add/update with new actions
            foreach ( $new_actions as $action ) {
                if ( !empty( $action['url'] ) ) {
                    $actions_by_url[$action['url']] = $action;
                }
            }
            update_option( 'cpanalyzer_page_actions', array_values( $actions_by_url ), false );
            $this->sync_actions_to_uploaded_url_rows( $new_actions );
        }

        /**
         * Merge AI actions with existing ones
         *
         * @param array $new_actions
         * @return void
         */
        private function merge_ai_actions( $new_actions ) {
            $existing_actions = get_option( 'cpanalyzer_page_actions', array() );
            $actions_by_url = array();
            foreach ( $existing_actions as $action ) {
                if ( !empty( $action['url'] ) ) {
                    $actions_by_url[$action['url']] = $action;
                }
            }
            foreach ( $new_actions as $action ) {
                if ( !empty( $action['url'] ) ) {
                    $actions_by_url[$action['url']] = $action;
                }
            }
            update_option( 'cpanalyzer_page_actions', array_values( $actions_by_url ), false );
            $this->sync_actions_to_uploaded_url_rows( $new_actions );
        }

        /**
         * Mirror AI actions into uploaded URL rows so table storage remains canonical.
         *
         * @param array $actions Actions keyed by URL payload.
         * @return void
         */
        private function sync_actions_to_uploaded_url_rows( $actions ) {
            global $wpdb;
            if ( empty( $actions ) || !is_array( $actions ) ) {
                return;
            }
            $table_name = $wpdb->prefix . 'cpanalyzer_urls';
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table existence check
            $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
            if ( $table_exists !== $table_name ) {
                return;
            }
            foreach ( $actions as $action ) {
                $url = $action['url'] ?? '';
                if ( empty( $url ) ) {
                    continue;
                }
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table lookup for canonical AI storage
                $existing_row = $wpdb->get_row( $wpdb->prepare( 'SELECT id, ai_suggestions FROM ' . esc_sql( $table_name ) . ' WHERE url = %s LIMIT 1', $url ) );
                if ( !$existing_row ) {
                    continue;
                }
                $existing_ai = json_decode( $existing_row->ai_suggestions ?? '', true );
                if ( !is_array( $existing_ai ) ) {
                    $existing_ai = array();
                }
                $merged_ai = array_merge( $existing_ai, $action );
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table update for canonical AI storage
                $wpdb->update( $table_name, array(
                    'ai_suggestions' => wp_json_encode( $merged_ai ),
                ), array(
                    'id' => intval( $existing_row->id ),
                ) );
            }
        }

        /**
         * Extract semantic insights from content data for AI payload
         *
         * @param array $content Content analysis data
         * @return array Processed semantic insights for AI
         */
        private function extract_semantic_insights( $content ) {
            if ( !isset( $content['semantic_analysis'] ) ) {
                return array();
            }
            $semantic = $content['semantic_analysis'];
            // Extract key insights for AI processing
            $insights = array(
                'topic_keywords'         => array_keys( array_slice(
                    $semantic['topic_keywords'] ?? array(),
                    0,
                    5,
                    true
                ) ),
                'keyword_density_issues' => !empty( $semantic['keyword_density']['over_optimized'] ?? array() ),
                'content_depth_score'    => $semantic['content_depth_score']['depth_score'] ?? 0,
                'content_themes'         => array_keys( $semantic['content_themes']['primary_themes'] ?? array() ),
                'content_type_signals'   => $semantic['semantic_structure']['content_type_signals'] ?? array(),
                'freshness_score'        => $semantic['content_freshness_indicators']['freshness_score'] ?? 0,
                'has_how_to_structure'   => $semantic['semantic_structure']['has_how_to_structure'] ?? false,
                'action_words_count'     => $semantic['semantic_structure']['action_words_count'] ?? 0,
                'question_count'         => $semantic['semantic_structure']['question_count'] ?? 0,
            );
            // Add content quality indicators
            $insights['content_quality_flags'] = array();
            if ( $insights['content_depth_score'] < 40 ) {
                $insights['content_quality_flags'][] = 'shallow_content';
            }
            if ( $insights['freshness_score'] < 20 ) {
                $insights['content_quality_flags'][] = 'outdated_content';
            }
            if ( empty( $insights['topic_keywords'] ) ) {
                $insights['content_quality_flags'][] = 'unclear_focus';
            }
            if ( $insights['keyword_density_issues'] ) {
                $insights['content_quality_flags'][] = 'keyword_stuffing';
            }
            return $insights;
        }

        /**
         * Detect content type based on multiple signals (Phase 3)
         *
         * @param array $page_data Complete page data including URL, content analysis, and metrics.
         * @return string Detected content type.
         */
        private function detect_content_type( $page_data ) {
            $signals = array(
                'blog_post'      => 0,
                'product_page'   => 0,
                'landing_page'   => 0,
                'service_page'   => 0,
                'corporate_page' => 0,
                'homepage'       => 0,
            );
            // Combine all detection methods.
            $url_signals = $this->analyze_url_patterns( $page_data );
            $content_signals = $this->analyze_content_structure( $page_data );
            $keyword_signals = $this->analyze_keyword_intent( $page_data );
            $semantic_signals = $this->analyze_semantic_signals( $page_data );
            // Merge all signals.
            foreach ( array(
                $url_signals,
                $content_signals,
                $keyword_signals,
                $semantic_signals
            ) as $signal_set ) {
                foreach ( $signal_set as $type => $score ) {
                    if ( isset( $signals[$type] ) ) {
                        $signals[$type] += absint( $score );
                    }
                }
            }
            /**
             * Filter content type detection signals.
             *
             * @since 1.0.0
             *
             * @param array $signals   Content type signals with scores.
             * @param array $page_data Complete page data.
             */
            $signals = apply_filters( 'cpanalyzer_content_type_signals', $signals, $page_data );
            // Return the type with highest confidence.
            arsort( $signals );
            $detected_type = array_key_first( $signals );
            $confidence = $signals[$detected_type];
            // Fallback to 'general' if confidence is too low.
            return ( $confidence >= 3 ? $detected_type : 'general' );
        }

        /**
         * Analyze URL patterns for content type detection
         *
         * @param array $page_data Page data including URL.
         * @return array Content type signals from URL analysis.
         */
        private function analyze_url_patterns( $page_data ) {
            $url = esc_url_raw( $page_data['url'] ?? '' );
            $signals = array();
            if ( empty( $url ) ) {
                return $signals;
            }
            // Blog/article patterns.
            if ( preg_match( '/\\/(blog|articles|news|posts|insights)\\//', $url ) ) {
                $signals['blog_post'] = 4;
            }
            // Product/shop patterns.
            if ( preg_match( '/\\/(product|shop|store|buy|item)\\//', $url ) ) {
                $signals['product_page'] = 4;
            }
            // Landing page patterns.
            if ( preg_match( '/\\/(landing|lp|campaign|offer|signup|trial)\\//', $url ) ) {
                $signals['landing_page'] = 4;
            }
            // Service page patterns.
            if ( preg_match( '/\\/(services|consulting|hire|solutions)\\//', $url ) ) {
                $signals['service_page'] = 4;
            }
            // Corporate page patterns.
            if ( preg_match( '/\\/(about|contact|team|company|careers)\\//', $url ) ) {
                $signals['corporate_page'] = 4;
            }
            // Homepage patterns.
            if ( preg_match( '/^\\/?$|\\/home\\/?$|\\/index/', $url ) ) {
                $signals['homepage'] = 4;
            }
            return $signals;
        }

        /**
         * Analyze content structure for type detection
         *
         * @param array $page_data Page data including content analysis.
         * @return array Content type signals from structure analysis.
         */
        private function analyze_content_structure( $page_data ) {
            $content = $page_data['content_analysis'] ?? array();
            $signals = array();
            // Form presence indicates landing pages.
            if ( !empty( $content['has_forms'] ) && !empty( $content['cta_count'] ) && $content['cta_count'] > 2 ) {
                $signals['landing_page'] = 3;
            }
            // Multiple CTAs suggest product pages.
            if ( !empty( $content['cta_count'] ) && $content['cta_count'] > 3 ) {
                $signals['product_page'] = 2;
            }
            // Long content with structure suggests blog posts.
            $word_count = $content['word_count'] ?? 0;
            $h2_count = $content['h2_count'] ?? 0;
            if ( $word_count > 800 && $h2_count > 3 ) {
                $signals['blog_post'] = 2;
            }
            // Service pages: moderate content with forms.
            if ( $word_count > 300 && $word_count < 1000 && !empty( $content['has_forms'] ) && !empty( $content['cta_count'] ) && $content['cta_count'] <= 2 ) {
                $signals['service_page'] = 2;
            }
            // Corporate pages: moderate content, few CTAs.
            if ( $word_count > 200 && $word_count < 800 && (empty( $content['cta_count'] ) || $content['cta_count'] <= 1) ) {
                $signals['corporate_page'] = 1;
            }
            return $signals;
        }

        /**
         * Analyze keyword intent for content type detection
         *
         * @param array $page_data Page data including GSC metrics.
         * @return array Content type signals from keyword analysis.
         */
        private function analyze_keyword_intent( $page_data ) {
            $gsc_keywords = $page_data['gsc_metrics']['top_keywords'] ?? array();
            $signals = array();
            foreach ( $gsc_keywords as $keyword_data ) {
                $keyword = sanitize_text_field( strtolower( $keyword_data['keyword'] ?? '' ) );
                if ( empty( $keyword ) ) {
                    continue;
                }
                // Informational intent (blog posts).
                if ( preg_match( '/\\b(how to|what is|guide|tutorial|tips|learn|why|when|where)\\b/', $keyword ) ) {
                    $signals['blog_post'] = ($signals['blog_post'] ?? 0) + 2;
                }
                // Commercial intent (product pages).
                if ( preg_match( '/\\b(buy|price|cost|cheap|best|review|vs|compare|features)\\b/', $keyword ) ) {
                    $signals['product_page'] = ($signals['product_page'] ?? 0) + 2;
                }
                // Transactional intent (landing pages).
                if ( preg_match( '/\\b(free trial|demo|signup|download|get|start|join)\\b/', $keyword ) ) {
                    $signals['landing_page'] = ($signals['landing_page'] ?? 0) + 2;
                }
                // Service intent.
                if ( preg_match( '/\\b(service|consultation|hire|contact|quote|help)\\b/', $keyword ) ) {
                    $signals['service_page'] = ($signals['service_page'] ?? 0) + 2;
                }
                // Brand/company intent.
                if ( preg_match( '/\\b(about|company|team|contact|careers)\\b/', $keyword ) ) {
                    $signals['corporate_page'] = ($signals['corporate_page'] ?? 0) + 1;
                }
            }
            return $signals;
        }

        /**
         * Analyze semantic signals for content type detection
         *
         * @param array $page_data Page data including semantic analysis.
         * @return array Content type signals from semantic analysis.
         */
        private function analyze_semantic_signals( $page_data ) {
            $semantic = $page_data['content_analysis']['semantic_analysis'] ?? array();
            $signals = array();
            // Content type signals from Phase 1.
            $content_type_signals = $semantic['content_type_signals'] ?? array();
            if ( in_array( 'how_to_guide', $content_type_signals, true ) ) {
                $signals['blog_post'] = 3;
            }
            if ( in_array( 'listicle', $content_type_signals, true ) ) {
                $signals['blog_post'] = ($signals['blog_post'] ?? 0) + 2;
            }
            if ( in_array( 'review', $content_type_signals, true ) ) {
                $signals['product_page'] = 2;
            }
            if ( in_array( 'comparison', $content_type_signals, true ) ) {
                $signals['product_page'] = ($signals['product_page'] ?? 0) + 1;
                $signals['blog_post'] = ($signals['blog_post'] ?? 0) + 1;
            }
            // Content themes analysis.
            $content_themes = $semantic['content_themes'] ?? array();
            if ( !empty( $content_themes ) && is_array( $content_themes ) ) {
                foreach ( $content_themes as $theme ) {
                    switch ( $theme ) {
                        case 'educational':
                            $signals['blog_post'] = ($signals['blog_post'] ?? 0) + 2;
                            break;
                        case 'business':
                            $signals['service_page'] = ($signals['service_page'] ?? 0) + 2;
                            break;
                        case 'technology':
                            $signals['product_page'] = ($signals['product_page'] ?? 0) + 1;
                            break;
                    }
                }
            }
            // Content quality flags.
            $quality_flags = $semantic['content_quality_flags'] ?? array();
            if ( in_array( 'shallow_content', $quality_flags, true ) ) {
                $signals['landing_page'] = ($signals['landing_page'] ?? 0) + 1;
            }
            return $signals;
        }

        /**
         * Generate context-aware system prompt based on content type (Phase 3)
         *
         * @param array $batch_data  Batch of page data for analysis.
         * @param bool  $is_retry    Whether this is a retry attempt.
         * @return string Context-aware system prompt.
         */
        private function generate_contextual_prompt( $batch_data, $is_retry = false ) {
            // Get base prompt.
            $base_prompt = $this->get_enhanced_system_prompt( $is_retry );
            // Analyze content types in batch.
            $content_types = array();
            $performance_issues = array();
            foreach ( $batch_data as $page_data ) {
                $content_type = $this->detect_content_type( $page_data );
                $content_types[$content_type] = ($content_types[$content_type] ?? 0) + 1;
                // Check for performance patterns.
                if ( !empty( $page_data['flag'] ) ) {
                    $performance_issues[$page_data['flag']] = ($performance_issues[$page_data['flag']] ?? 0) + 1;
                }
            }
            // Generate context-specific additions.
            $context_additions = $this->build_context_additions( $content_types, $performance_issues );
            // Generate actionable recommendations for each page to guide AI specificity.
            $actionable_guidance = $this->build_actionable_guidance( $batch_data );
            /**
             * Filter the context-aware prompt additions.
             *
             * @since 1.0.0
             *
             * @param string $context_additions Context-specific prompt additions.
             * @param array  $content_types     Content types found in batch.
             * @param array  $performance_issues Performance issues found in batch.
             * @param array  $batch_data        Complete batch data.
             */
            $context_additions = apply_filters(
                'cpanalyzer_contextual_prompt_additions',
                $context_additions,
                $content_types,
                $performance_issues,
                $batch_data
            );
            if ( !empty( $context_additions ) ) {
                $base_prompt .= "\n\n" . $context_additions;
            }
            if ( !empty( $actionable_guidance ) ) {
                $base_prompt .= "\n\n" . $actionable_guidance;
            }
            return $base_prompt;
        }

        /**
         * Build context-specific prompt additions
         *
         * @param array $content_types     Content types in batch.
         * @param array $performance_issues Performance issues in batch.
         * @return string Context-specific prompt additions.
         */
        private function build_context_additions( $content_types, $performance_issues ) {
            $additions = array();
            // Add content type specific guidance.
            foreach ( $content_types as $type => $count ) {
                if ( $count > 0 ) {
                    $type_guidance = $this->get_content_type_guidance( $type );
                    if ( !empty( $type_guidance ) ) {
                        $additions[] = $type_guidance;
                    }
                }
            }
            // Add performance pattern guidance.
            foreach ( $performance_issues as $issue => $count ) {
                if ( $count > 0 ) {
                    $issue_guidance = $this->get_performance_issue_guidance( $issue );
                    if ( !empty( $issue_guidance ) ) {
                        $additions[] = $issue_guidance;
                    }
                }
            }
            return implode( "\n\n", $additions );
        }

        /**
         * Get content type specific guidance
         *
         * @param string $content_type Content type.
         * @return string Content type guidance.
         */
        private function get_content_type_guidance( $content_type ) {
            switch ( $content_type ) {
                case 'blog_post':
                    return "BLOG POST OPTIMIZATION:\n" . "For blog posts in this batch, prioritize:\n" . "- Content freshness and publication date updates\n" . "- Internal linking to related articles and pillar pages\n" . "- Topic authority signals and expert citations\n" . "- Featured snippet optimization for informational queries\n" . "- Social sharing and engagement improvements\n" . '- Long-tail keyword expansion based on content themes';
                case 'product_page':
                    return "PRODUCT PAGE OPTIMIZATION:\n" . "For product pages in this batch, prioritize:\n" . "- Conversion rate optimization and purchase funnel improvements\n" . "- Product information clarity and feature highlights\n" . "- Customer review integration and social proof\n" . "- Commercial intent keyword targeting\n" . "- Pricing strategy and competitive positioning\n" . '- Trust signals and security indicators';
                case 'landing_page':
                    return "LANDING PAGE OPTIMIZATION:\n" . "For landing pages in this batch, prioritize:\n" . "- CTA optimization and conversion rate improvements\n" . "- Lead generation form optimization\n" . "- Value proposition clarity and benefit communication\n" . "- Friction reduction and user experience improvements\n" . "- A/B testing recommendations for key elements\n" . '- Mobile conversion optimization';
                case 'service_page':
                    return "SERVICE PAGE OPTIMIZATION:\n" . "For service pages in this batch, prioritize:\n" . "- Service benefit communication and value proposition\n" . "- Contact form optimization and lead capture\n" . "- Case studies and client testimonial integration\n" . "- Local SEO optimization for service-based queries\n" . "- Process explanation and consultation CTAs\n" . '- Trust building and credibility indicators';
                case 'corporate_page':
                    return "CORPORATE PAGE OPTIMIZATION:\n" . "For corporate pages in this batch, prioritize:\n" . "- Brand trust and credibility signals\n" . "- Contact information accessibility\n" . "- Company story and value communication\n" . "- Team expertise highlighting\n" . "- Professional image and authority building\n" . '- Navigation to key business pages';
                case 'homepage':
                    return "HOMEPAGE OPTIMIZATION:\n" . "For homepages in this batch, prioritize:\n" . "- Clear value proposition and brand messaging\n" . "- User journey optimization and navigation clarity\n" . "- Key service/product highlighting\n" . "- Trust signals and social proof above fold\n" . "- Multiple conversion path optimization\n" . '- Brand keyword targeting and authority';
                default:
                    return '';
            }
        }

        /**
         * Get performance issue specific guidance
         *
         * @param string $issue Performance issue type.
         * @return string Performance issue guidance.
         */
        private function get_performance_issue_guidance( $issue ) {
            switch ( $issue ) {
                case 'decay':
                    return "TRAFFIC DECLINE RECOVERY:\n" . "For pages with traffic decline, focus on:\n" . "- Content refresh and information updates\n" . "- Technical SEO issues that may block crawlers\n" . "- Keyword cannibalization analysis\n" . "- Competitor content gap analysis\n" . "- Historical content optimization\n" . '- Re-engagement and promotional strategies';
                case 'seo':
                    return "SEO OPPORTUNITY OPTIMIZATION:\n" . "For high-impression, low-CTR pages, focus on:\n" . "- Title tag optimization for target keywords\n" . "- Meta description compelling copy\n" . "- Featured snippet targeting\n" . "- SERP click-through rate improvements\n" . "- Search intent alignment\n" . '- Keyword positioning optimization';
                case 'conversion':
                    return "CONVERSION OPTIMIZATION:\n" . "For high-traffic, low-conversion pages, focus on:\n" . "- CTA placement and design optimization\n" . "- Form field reduction and simplification\n" . "- Trust signal placement\n" . "- Value proposition clarity\n" . "- Mobile conversion experience\n" . '- Friction point identification and removal';
                case 'performance':
                    return "PERFORMANCE OPTIMIZATION:\n" . "For Core Web Vitals failing pages, focus on:\n" . "- Page loading speed improvements\n" . "- Mobile user experience optimization\n" . "- Technical performance fixes\n" . "- User experience and accessibility\n" . "- Image and resource optimization\n" . '- Critical rendering path optimization';
                default:
                    return '';
            }
        }

        /**
         * Build actionable guidance for AI prompts based on data-driven analysis.
         *
         * @since 1.0.0
         * @param array $batch_data Batch of page data for analysis.
         * @return string Actionable guidance for AI prompts.
         */
        private function build_actionable_guidance( $batch_data ) {
            $guidance_parts = array();
            // Analyze each page for specific guidance opportunities.
            foreach ( $batch_data as $page_data ) {
                if ( empty( $page_data['content_analysis'] ) ) {
                    continue;
                }
                $content_data = $page_data['content_analysis'];
                $page_guidance = array();
                // Performance-specific guidance.
                if ( !empty( $content_data['psi'] ) ) {
                    $psi_data = $content_data['psi'];
                    $strategy = $psi_data['primary_strategy'] ?? 'mobile';
                    $strategy_data = $psi_data[$strategy] ?? array();
                    if ( !empty( $strategy_data ) ) {
                        $current_lcp = $strategy_data['metrics']['LCP_ms'] ?? 0;
                        $current_score = $strategy_data['score'] ?? 100;
                        if ( $current_lcp > 2500 ) {
                            $page_guidance[] = sprintf( 'LCP is %dms (needs improvement below 2500ms)', $current_lcp );
                        }
                        if ( $current_score < 50 ) {
                            $page_guidance[] = sprintf( 'Performance score is %d/100 (target: 50+)', $current_score );
                        }
                    }
                }
                // SEO-specific guidance.
                $word_count = $content_data['word_count'] ?? 0;
                if ( $word_count > 0 && $word_count < 300 ) {
                    $page_guidance[] = sprintf( 'Content is thin (%d words, target: 300+)', $word_count );
                }
                if ( !empty( $content_data['images_without_alt'] ) ) {
                    $missing_alt = ( is_array( $content_data['images_without_alt'] ) ? count( $content_data['images_without_alt'] ) : intval( $content_data['images_without_alt'] ) );
                    if ( $missing_alt > 0 ) {
                        $page_guidance[] = sprintf( '%d images missing alt text', $missing_alt );
                    }
                }
                $internal_links = $content_data['internal_links'] ?? 0;
                if ( $internal_links < 3 ) {
                    $page_guidance[] = sprintf( 'Only %d internal links (target: 3-5)', $internal_links );
                }
                // Add page-specific guidance if any issues found.
                if ( !empty( $page_guidance ) ) {
                    $url = $page_data['url'] ?? 'unknown';
                    $guidance_parts[] = sprintf( 'For %s: %s', esc_url_raw( $url ), implode( ', ', $page_guidance ) );
                }
            }
            if ( empty( $guidance_parts ) ) {
                return '';
            }
            return "SPECIFIC DATA-DRIVEN INSIGHTS:\n" . "Base your recommendations on these measured metrics:\n" . implode( "\n", $guidance_parts ) . "\n\n" . 'CRITICAL: Only suggest improvements for the specific issues listed above. ' . 'Use exact numbers provided. Do not recommend actions for unmeasured issues.';
        }

        /**
         * Generate actionable, data-driven recommendations based on measurable metrics.
         *
         * @since 1.0.0
         * @param array $page_data Complete page data including content analysis and PSI metrics.
         * @param array $ga_data   Google Analytics data for traffic context.
         * @return array Array of prioritized, actionable recommendations.
         */
        public function generate_actionable_recommendations( $page_data, $ga_data = array() ) {
            $recommendations = array();
            // Only process if we have reliable data.
            if ( empty( $page_data['content_analysis'] ) ) {
                return $recommendations;
            }
            $content_data = $page_data['content_analysis'];
            // Performance recommendations based on PSI data.
            if ( !empty( $content_data['psi'] ) ) {
                $performance_recs = $this->analyze_performance_opportunities( $content_data, $ga_data );
                $recommendations = array_merge( $recommendations, $performance_recs );
            }
            // SEO recommendations based on content analysis.
            $seo_recs = $this->analyze_seo_opportunities( $content_data, $ga_data );
            $recommendations = array_merge( $recommendations, $seo_recs );
            // Sort by impact score (highest first).
            usort( $recommendations, function ( $a, $b ) {
                return $b['impact_score'] - $a['impact_score'];
            } );
            // Filter to only high-confidence recommendations.
            $recommendations = array_filter( $recommendations, function ( $rec ) {
                return $rec['confidence'] === 'HIGH';
            } );
            /**
             * Filter the generated actionable recommendations.
             *
             * @since 1.0.0
             * @param array $recommendations Generated recommendations.
             * @param array $page_data       Page data used for analysis.
             * @param array $ga_data         Google Analytics data.
             */
            return apply_filters(
                'cpanalyzer_actionable_recommendations',
                $recommendations,
                $page_data,
                $ga_data
            );
        }

        /**
         * Analyze performance opportunities based on PSI data.
         *
         * @since 1.0.0
         * @param array $content_data Content analysis data including PSI metrics.
         * @param array $ga_data      Google Analytics data for impact calculation.
         * @return array Array of performance recommendations.
         */
        private function analyze_performance_opportunities( $content_data, $ga_data ) {
            $recommendations = array();
            $psi_data = $content_data['psi'];
            // Get primary strategy PSI data.
            $strategy = $psi_data['primary_strategy'] ?? 'mobile';
            $strategy_data = $psi_data[$strategy] ?? array();
            if ( empty( $strategy_data ) ) {
                return $recommendations;
            }
            $current_score = $strategy_data['score'] ?? 100;
            $current_lcp = $strategy_data['metrics']['LCP_ms'] ?? 0;
            $page_views = $ga_data['views'] ?? 0;
            // Recommendation 1: Large images (measurable threshold).
            if ( !empty( $content_data['images_without_alt'] ) && is_array( $content_data['images_without_alt'] ) ) {
                $large_images = $this->detect_large_images( $content_data );
                foreach ( $large_images as $image ) {
                    $impact_data = $this->calculate_image_impact( $image['size_estimate'], $current_lcp );
                    $recommendations[] = array(
                        'type'            => 'performance',
                        'category'        => 'image_optimization',
                        'priority'        => 'HIGH',
                        'title'           => 'Compress Large Image',
                        'description'     => sprintf( 'Compress %s for faster loading. Current estimated size suggests significant LCP improvement potential.', esc_html( $image['filename'] ) ),
                        'specifics'       => array(
                            'file'             => $image['filename'],
                            'estimated_impact' => $impact_data['lcp_improvement_ms'] . 'ms LCP improvement',
                            'implementation'   => 'Use image compression tools or WebP format conversion',
                        ),
                        'confidence'      => $impact_data['confidence'],
                        'impact_score'    => $impact_data['impact_score'],
                        'business_impact' => $this->calculate_business_impact( $impact_data['lcp_improvement_ms'], $page_views ),
                    );
                }
            }
            // Recommendation 2: Poor Core Web Vitals scores.
            if ( $current_lcp > 2500 ) {
                $lcp_improvement = $current_lcp - 2500;
                $recommendations[] = array(
                    'type'            => 'performance',
                    'category'        => 'core_web_vitals',
                    'priority'        => 'HIGH',
                    'title'           => 'Improve Largest Contentful Paint (LCP)',
                    'description'     => sprintf( 'Current LCP is %dms (target: <2500ms). Focus on optimizing above-fold content loading.', $current_lcp ),
                    'specifics'       => array(
                        'current_lcp'        => $current_lcp . 'ms',
                        'target_lcp'         => '2500ms',
                        'improvement_needed' => $lcp_improvement . 'ms',
                        'implementation'     => 'Optimize images, defer non-critical CSS, preload key resources',
                    ),
                    'confidence'      => 'HIGH',
                    'impact_score'    => min( 10, floor( $lcp_improvement / 200 ) ),
                    'business_impact' => $this->calculate_business_impact( $lcp_improvement * 0.7, $page_views ),
                );
            }
            // Recommendation 3: Low performance score.
            if ( $current_score < 50 ) {
                $score_improvement = 50 - $current_score;
                $recommendations[] = array(
                    'type'            => 'performance',
                    'category'        => 'overall_score',
                    'priority'        => 'MEDIUM',
                    'title'           => 'Improve Overall Performance Score',
                    'description'     => sprintf( 'Current PageSpeed score is %d/100. Target minimum score of 50 for good user experience.', $current_score ),
                    'specifics'       => array(
                        'current_score'      => $current_score . '/100',
                        'target_score'       => '50/100',
                        'improvement_needed' => $score_improvement . ' points',
                        'implementation'     => 'Address PSI opportunities: image optimization, resource loading, code efficiency',
                    ),
                    'confidence'      => 'HIGH',
                    'impact_score'    => min( 8, $score_improvement / 5 ),
                    'business_impact' => $this->calculate_business_impact( $score_improvement * 50, $page_views ),
                );
            }
            return $recommendations;
        }

        /**
         * Analyze SEO opportunities based on content analysis.
         *
         * @since 1.0.0
         * @param array $content_data Content analysis data.
         * @param array $ga_data      Google Analytics data for impact calculation.
         * @return array Array of SEO recommendations.
         */
        private function analyze_seo_opportunities( $content_data, $ga_data ) {
            $recommendations = array();
            $page_views = $ga_data['views'] ?? 0;
            // Recommendation 1: Missing alt text (measurable count).
            if ( !empty( $content_data['images_without_alt'] ) ) {
                $missing_alt_count = ( is_array( $content_data['images_without_alt'] ) ? count( $content_data['images_without_alt'] ) : intval( $content_data['images_without_alt'] ) );
                if ( $missing_alt_count > 0 ) {
                    $recommendations[] = array(
                        'type'            => 'seo',
                        'category'        => 'accessibility',
                        'priority'        => 'MEDIUM',
                        'title'           => 'Add Missing Alt Text',
                        'description'     => sprintf( '%d images are missing alt text. This impacts both SEO and accessibility.', $missing_alt_count ),
                        'specifics'       => array(
                            'affected_images' => $missing_alt_count,
                            'implementation'  => 'Add descriptive alt attributes to all images for better SEO and accessibility',
                            'seo_benefit'     => 'Improved image search rankings and accessibility compliance',
                        ),
                        'confidence'      => 'HIGH',
                        'impact_score'    => min( 6, $missing_alt_count ),
                        'business_impact' => array(
                            'seo_improvement' => 'Better image search visibility',
                            'accessibility'   => 'Improved user experience for screen readers',
                        ),
                    );
                }
            }
            // Recommendation 2: Thin content (measurable word count).
            $word_count = $content_data['word_count'] ?? 0;
            if ( $word_count > 0 && $word_count < 300 ) {
                $words_needed = 300 - $word_count;
                $recommendations[] = array(
                    'type'            => 'seo',
                    'category'        => 'content_depth',
                    'priority'        => 'MEDIUM',
                    'title'           => 'Expand Content Length',
                    'description'     => sprintf( 'Content is only %d words. Pages with 300+ words typically perform better in search results.', $word_count ),
                    'specifics'       => array(
                        'current_words'     => $word_count,
                        'recommended_min'   => '300 words',
                        'additional_needed' => $words_needed . ' words',
                        'implementation'    => 'Add more detailed information, examples, or related topics',
                    ),
                    'confidence'      => 'HIGH',
                    'impact_score'    => min( 7, floor( $words_needed / 50 ) ),
                    'business_impact' => array(
                        'search_ranking' => 'Better chances of ranking for target keywords',
                        'user_value'     => 'More comprehensive information for visitors',
                    ),
                );
            }
            // Recommendation 3: Missing internal links (measurable count).
            $internal_links = $content_data['internal_links'] ?? 0;
            if ( $internal_links < 3 && $page_views > 100 ) {
                $links_suggested = 3 - $internal_links;
                $recommendations[] = array(
                    'type'            => 'seo',
                    'category'        => 'internal_linking',
                    'priority'        => 'LOW',
                    'title'           => 'Add Internal Links',
                    'description'     => sprintf( 'Page has only %d internal links. Adding 2-3 relevant internal links improves SEO and user navigation.', $internal_links ),
                    'specifics'       => array(
                        'current_links'   => $internal_links,
                        'recommended_min' => '3-5 internal links',
                        'links_to_add'    => $links_suggested,
                        'implementation'  => 'Link to related pages, services, or relevant blog posts',
                    ),
                    'confidence'      => 'MEDIUM',
                    'impact_score'    => min( 4, $links_suggested ),
                    'business_impact' => array(
                        'seo_benefit'    => 'Better page authority distribution',
                        'user_retention' => 'Improved site navigation and engagement',
                    ),
                );
            }
            return $recommendations;
        }

        /**
         * Detect large images from content analysis.
         *
         * @since 1.0.0
         * @param array $content_data Content analysis data.
         * @return array Array of large image details.
         */
        private function detect_large_images( $content_data ) {
            $large_images = array();
            // This is a conservative approach since we don't have actual file sizes.
            // We infer from content analysis patterns.
            if ( !empty( $content_data['images_without_alt'] ) && is_array( $content_data['images_without_alt'] ) ) {
                foreach ( $content_data['images_without_alt'] as $index => $image_data ) {
                    // Estimate potential large images based on common patterns.
                    $filename = ( is_string( $image_data ) ? basename( $image_data ) : "image_{$index}" );
                    // Conservative size estimate for optimization recommendations.
                    $large_images[] = array(
                        'filename'      => $filename,
                        'size_estimate' => 800,
                    );
                    // Limit to top 3 to avoid overwhelming users.
                    if ( count( $large_images ) >= 3 ) {
                        break;
                    }
                }
            }
            return $large_images;
        }

        /**
         * Calculate potential impact of image optimization.
         *
         * @since 1.0.0
         * @param int $estimated_size_kb Estimated image size in KB.
         * @param int $current_lcp_ms    Current LCP in milliseconds.
         * @return array Impact calculation results.
         */
        private function calculate_image_impact( $estimated_size_kb, $current_lcp_ms ) {
            // Conservative calculation based on research.
            $size_mb = $estimated_size_kb / 1024;
            // Conservative estimate: 1MB ≈ 200ms LCP impact.
            $potential_savings = round( $size_mb * 200 * 0.6 );
            // 60% optimization efficiency.
            $impact_score = 0;
            if ( $estimated_size_kb > 500 ) {
                $impact_score += 3;
            }
            if ( $current_lcp_ms > 2500 ) {
                $impact_score += 2;
            }
            if ( $potential_savings > 300 ) {
                $impact_score += 3;
            }
            return array(
                'lcp_improvement_ms' => $potential_savings,
                'confidence'         => ( $estimated_size_kb > 500 ? 'HIGH' : 'MEDIUM' ),
                'impact_score'       => min( $impact_score, 8 ),
            );
        }

        /**
         * Generate a "Content Refresh Brief" for a single URL.
         *
         * Bundles scraped content (body text, headings, meta), GA4 stats,
         * GSC keywords/rank, and PSI scores into one structured prompt and
         * asks the AI to return a three-part brief:
         *   - gap_analysis       : missing keywords / topic gaps from GSC data
         *   - structural_plan    : new headers/sections to improve SEO
         *   - performance_fixes  : actionable PSI/GA4 fixes
         *
         * @param  array $url_data  Row from wp_cpanalyzer_urls (decoded columns).
         * @return array|false      Decoded JSON brief, or false on failure.
         */
        public function generate_content_refresh_brief( $url_data ) {
            if ( !$this->is_configured() ) {
                return false;
            }
            $url = $url_data['url'] ?? '';
            $ga_data = ( is_array( $url_data['ga_data'] ) ? $url_data['ga_data'] : (array) json_decode( $url_data['ga_data'] ?? '{}', true ) );
            $gsc_data = ( is_array( $url_data['gsc_data'] ) ? $url_data['gsc_data'] : (array) json_decode( $url_data['gsc_data'] ?? '{}', true ) );
            $content_data = ( is_array( $url_data['content_data'] ) ? $url_data['content_data'] : (array) json_decode( $url_data['content_data'] ?? '{}', true ) );
            // ---- Build a compact context bundle ----------------------------
            // Headings (H1–H3)
            $headings = array();
            foreach ( array('h1_tags', 'h2_tags', 'h3_tags') as $level ) {
                foreach ( $content_data[$level] ?? array() as $h ) {
                    $headings[] = $h['text'] ?? '';
                }
            }
            // PSI summary
            $psi_summary = array();
            $psi_raw = $content_data['psi'] ?? array();
            if ( !empty( $psi_raw ) ) {
                $strategy = $psi_raw['primary_strategy'] ?? 'mobile';
                $strat_data = $psi_raw[$strategy] ?? array();
                $psi_summary = array(
                    'score'         => $strat_data['score'] ?? null,
                    'cwv_pass'      => $strat_data['core_web_vitals_pass'] ?? null,
                    'LCP_ms'        => $strat_data['metrics']['LCP_ms'] ?? null,
                    'INP_ms'        => $strat_data['metrics']['INP_ms'] ?? null,
                    'CLS'           => $strat_data['metrics']['CLS'] ?? null,
                    'opportunities' => array_slice( $strat_data['opportunities'] ?? array(), 0, 3 ),
                );
            }
            // Truncated body text for context (max 1 500 chars in prompt).
            $body_text = mb_substr( wp_strip_all_tags( $content_data['body_text'] ?? '' ), 0, 1500 );
            $context = array(
                'url'           => $url,
                'title'         => $content_data['title']['text'] ?? '',
                'meta_desc'     => $content_data['meta_description']['text'] ?? '',
                'meta_keywords' => $content_data['meta_keywords'] ?? '',
                'headings'      => $headings,
                'body_excerpt'  => $body_text,
                'word_count'    => $content_data['word_count'] ?? 0,
                'ga4'           => array(
                    'views'      => $ga_data['views'] ?? 0,
                    'views_prev' => $ga_data['views_prev'] ?? 0,
                    'engagement' => $ga_data['engagement'] ?? 0,
                    'form_fills' => $ga_data['form_fills'] ?? 0,
                ),
                'gsc'           => array(
                    'impressions'  => $gsc_data['impressions'] ?? 0,
                    'clicks'       => $gsc_data['clicks'] ?? 0,
                    'ctr'          => $gsc_data['ctr'] ?? 0,
                    'position'     => $gsc_data['position'] ?? 0,
                    'top_keywords' => array_slice( $gsc_data['keywords'] ?? array(), 0, 5 ),
                ),
                'psi'           => $psi_summary,
            );
            // ---- Prompt ----------------------------------------------------
            $prompt = "You are a senior SEO strategist and content editor. Analyse the following page data and return ONLY a JSON object with exactly three keys.\n\n" . "PAGE DATA:\n" . wp_json_encode( $context, JSON_PRETTY_PRINT ) . "\n\n" . "REQUIRED JSON STRUCTURE:\n" . "{\n" . "  \"gap_analysis\": {\n" . "    \"missing_keywords\": [\"keyword1\", \"keyword2\", ...],\n" . "    \"competitor_gaps\": \"One-sentence assessment of topical gaps vs competitors\",\n" . "    \"intent_mismatch\": \"Describe any mismatch between content intent and search queries\"\n" . "  },\n" . "  \"structural_plan\": {\n" . "    \"recommended_sections\": [\n" . "      {\"heading\": \"Suggested H2\", \"purpose\": \"Why this section helps SEO/UX\"},\n" . "      ...\n" . "    ],\n" . "    \"current_issues\": \"Summary of structural problems\",\n" . "    \"ideal_outline\": [\"H1\", \"H2\", \"H2\", \"H2\", \"H3\", ...]\n" . "  },\n" . "  \"performance_fixes\": {\n" . "    \"ga4_actions\": [\"Specific fix based on traffic/engagement data\"],\n" . "    \"psi_actions\": [\"Specific Core Web Vitals or speed fix\"],\n" . "    \"priority\": \"high|medium|low\",\n" . "    \"estimated_impact\": \"Expected business outcome\"\n" . "  }\n" . "}\n\n" . "Rules: be specific, actionable, and concise. Return JSON only — no markdown, no commentary.";
            $raw = $this->call_openai_api( $prompt, array(
                'max_tokens'      => 1500,
                'temperature'     => 0.2,
                'response_format' => 'json_object',
            ) );
            if ( empty( $raw ) ) {
                return false;
            }
            // Strip markdown code fences if present.
            $raw = preg_replace( '/^```(?:json)?\\s*/i', '', trim( $raw ) );
            $raw = preg_replace( '/\\s*```$/', '', $raw );
            $brief = json_decode( $raw, true );
            if ( !is_array( $brief ) || !isset( $brief['gap_analysis'], $brief['structural_plan'], $brief['performance_fixes'] ) ) {
                return false;
            }
            // Tag as a refresh brief so we can distinguish it from legacy AI suggestions.
            $brief['_type'] = 'content_refresh_brief';
            $brief['_generated'] = time();
            return $brief;
        }

        /**
         * Calculate business impact of performance improvements.
         *
         * @since 1.0.0
         * @param int $lcp_improvement_ms LCP improvement in milliseconds.
         * @param int $monthly_views      Monthly page views.
         * @return array Business impact estimation.
         */
        private function calculate_business_impact( $lcp_improvement_ms, $monthly_views ) {
            if ( $monthly_views < 100 ) {
                return array(
                    'impact' => 'Limited traffic - focus on user experience',
                );
            }
            // Conservative estimate: 100ms ≈ 1% conversion improvement.
            $conversion_improvement = $lcp_improvement_ms / 100 * 0.01;
            $estimated_additional_conversions = $monthly_views * $conversion_improvement;
            return array(
                'performance_improvement'    => $lcp_improvement_ms . 'ms faster loading',
                'estimated_conversion_lift'  => round( $conversion_improvement * 100, 1 ) . '%',
                'additional_monthly_actions' => round( $estimated_additional_conversions ),
                'confidence'                 => ( $lcp_improvement_ms > 200 ? 'HIGH' : 'MEDIUM' ),
            );
        }

    }

}