# thinkrank/2.6.0/includes/ai/class-manager.php

ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console &amp; Local SEO, version 2.6.0. 1,765 lines.

- Page: https://pluginprobe.com/plugins/thinkrank/2.6.0/code/includes/ai/class-manager.php
- Raw: https://pluginprobe.com/plugins/thinkrank/2.6.0/raw/includes/ai/class-manager.php
- Modified: 2026-09-15T05:37:32+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/thinkrank/2.6.0/code/includes/ai/class-manager.php#L10-L20`.

```php
<?php
/**
 * AI Manager Class
 *
 * Handles AI provider integration and management
 *
 * @package ThinkRank\AI
 * @since 1.0.0
 */

declare(strict_types=1);

namespace ThinkRank\AI;

use ThinkRank\Core\Settings;


// Prevent direct access
if (!defined('ABSPATH')) {
    exit;
}

/**
 * AI Manager Class
 *
 * Single Responsibility: Manage AI providers and requests
 *
 * @since 1.0.0
 */
class Manager {

    /**
     * Settings instance
     *
     * @var Settings
     */
    private Settings $settings;



    /**
     * Cache manager instance
     *
     * @var Cache_Manager
     */
    private Cache_Manager $cache;

    /**
     * Current AI client
     *
     * @var OpenAI_Client|Claude_Client|null
     */
    private $client = null;


    /**
     * Constructor
     *
     * @param Settings|null $settings Settings instance
     */
    public function __construct(?Settings $settings = null) {
        $this->settings = $settings ?? Settings::instance();
        $this->cache = new Cache_Manager((int) $this->settings->get('cache_duration', 3600));
    }

    /**
     * Initialize AI manager
     *
     * @return void
     */
    public function init(): void {
        // Initialize AI client based on settings
        add_action('init', [$this, 'initialize_client']);

        // Schedule cache cleanup
        add_action('thinkrank_daily_cleanup', [$this, 'cleanup_cache']);

        // Add AJAX handlers for AI requests
        add_action('wp_ajax_thinkrank_generate_metadata', [$this, 'ajax_generate_metadata']);
        add_action('wp_ajax_thinkrank_test_api_connection', [$this, 'ajax_test_connection']);
    }

    /**
     * Initialize AI client
     *
     * @return void
     *
     * @throws \Exception On failure.
     */
    public function initialize_client(): void {
        $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);

        // No provider chosen yet (a fresh install, or the user cleared it). That
        // is a normal unconfigured state, not a failure — leave $this->client
        // null and let get_client_unavailable_message() explain it (#572).
        if (Settings::AI_PROVIDER_NONE === $provider) {
            return;
        }

        try {
            switch ($provider) {
                case 'openai':
                    $api_key = $this->settings->get('openai_api_key');
                    if ($api_key) {
                        // Allow any model id (incl. user-entered custom models);
                        // only fall back to the default when none is set.
                        $model = $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
                        if (empty($model)) {
                            $model = Settings::DEFAULT_OPENAI_MODEL;
                        }
                        // OpenAI's reasoning models (GPT-5/o-series) spend a long
                        // time on reasoning tokens before emitting content, so
                        // large completions (content briefs) regularly outlive the
                        // 120s used for the other providers. Give them 300s.
                        $timeout = 300;
                        $this->client = new OpenAI_Client($api_key, $model, $timeout);

                        // OpenAI client created successfully
                    }
                    break;

                case 'claude':
                    $api_key = $this->settings->get('claude_api_key');
                    if ($api_key) {
                        // Allow any model id (incl. user-entered custom models);
                        // only fall back to the default when none is set.
                        $model = $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
                        if (empty($model)) {
                            $model = Settings::DEFAULT_CLAUDE_MODEL;
                        }
                        // Use 120-second timeout for complex AI operations
                        $timeout = 120;
                        $this->client = new Claude_Client($api_key, $model, $timeout);

                        // Claude client created successfully
                    }
                    break;

                case 'gemini':
                    $api_key = $this->settings->get('gemini_api_key');
                    if ($api_key) {
                        // Allow any model id (incl. user-entered custom models);
                        // only fall back to the default when none is set.
                        $model = $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
                        if (empty($model)) {
                            $model = Settings::DEFAULT_GEMINI_MODEL;
                        }
                        // Use 120-second timeout for complex AI operations
                        $timeout = 120;
                        $this->client = new Gemini_Client($api_key, $model, $timeout);
                    }
                    break;

                case 'openrouter':
                    $api_key = $this->settings->get('openrouter_api_key');
                    if ($api_key) {
                        // Allow any model id (incl. user-entered custom models);
                        // only fall back to the default when none is set.
                        $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
                        if (empty($model)) {
                            $model = Settings::DEFAULT_OPENROUTER_MODEL;
                        }
                        // Use 120-second timeout for complex AI operations
                        $timeout = 120;
                        $this->client = new OpenRouter_Client($api_key, $model, $timeout);
                    }
                    break;

                default:
                    throw new \Exception("Unsupported AI provider: {$provider}");
            }
        } catch (\Exception $e) {
            // Leave a trace. Swallowing this meant a misconfigured provider
            // produced a NULL client and every AI feature became a silent
            // no-op with nothing to diagnose from.
            if (defined('WP_DEBUG') && WP_DEBUG) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic, WP_DEBUG only.
                error_log('ThinkRank [ai]: client initialization failed — ' . $e->getMessage());
            }
        }
    }

    /**
     * Get the display name of the currently selected AI provider
     *
     * @return string Provider display name (e.g. "OpenAI")
     */
    private function get_provider_label(): string {
        $labels = [
            'openai'     => 'OpenAI',
            // The vendor, not the model family — matches the settings UI (#572).
            'claude'     => 'Anthropic',
            'gemini'     => 'Gemini',
            'openrouter' => 'OpenRouter',
        ];

        $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);

        return $labels[$provider] ?? ucfirst($provider);
    }

    /**
     * Build a user-friendly message explaining why AI features are unavailable
     *
     * Provider-aware: tells the user exactly which API key is missing and where
     * to add it, instead of a generic "client not initialized" error.
     *
     * @return string Actionable error message for end users
     */
    private function get_client_unavailable_message(): string {
        $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);

        // The React admin renders this anchor as a real link via linkifyMessage().
        $settings_link = sprintf(
            '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
            esc_url(admin_url('admin.php?page=thinkrank-settings')),
            __('ThinkRank → Settings', 'thinkrank')
        );

        // No provider chosen at all — asking for a key would put the cart before
        // the horse, so name the actual first step (#572).
        if (Settings::AI_PROVIDER_NONE === $provider) {
            return sprintf(
                /* translators: %s: link to the ThinkRank settings page. */
                __('AI features are not set up yet. Choose an AI provider and add its API key under %s.', 'thinkrank'),
                $settings_link
            );
        }

        if (empty($this->settings->get("{$provider}_api_key"))) {
            return sprintf(
                /* translators: 1: AI provider name (e.g. OpenAI), 2: link to the ThinkRank settings page. */
                __('AI features are not set up yet. To enable them, add your %1$s API key under %2$s.', 'thinkrank'),
                $this->get_provider_label(),
                $settings_link
            );
        }

        return sprintf(
            /* translators: 1: AI provider name (e.g. OpenAI), 2: link to the ThinkRank settings page. */
            __('ThinkRank could not connect to %1$s. Please verify your API key and model under %2$s, then try again.', 'thinkrank'),
            $this->get_provider_label(),
            $settings_link
        );
    }

    /**
     * Force re-initialization of client (useful after settings change)
     *
     * @return void
     */
    public function reinitialize_client(): void {
        $this->client = null;
        $this->initialize_client();
    }

    /**
     * Get the AI client instance
     *
     * @return OpenAI_Client|Claude_Client|null AI client instance
     * @throws \Exception If client cannot be initialized
     */
    public function get_client() {
        // Initialize client if not already done
        if (!$this->client) {
            $this->initialize_client();
        }

        // If still not available, throw error
        if (!$this->client) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        return $this->client;
    }

    /**
     * Generate SEO metadata for content
     *
     * @param string $content Content to analyze
     * @param array $options Generation options
     * @return array Generated metadata
     * @throws \Exception If generation fails
     */
    public function generate_seo_metadata(string $content, array $options = []): array {
        // Check if client is available, try to initialize if not
        if (!$this->client) {
            $this->initialize_client();

            // If still not available, throw error
            if (!$this->client) {
                throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
            }
        }

        // Check rate limits
        if (!$this->check_rate_limit()) {
            throw new \Exception('Rate limit exceeded. Please try again later.');
        }

        // Get current user for logging
        $user_id = get_current_user_id();

        // Generate cache key
        $cache_key = $this->cache->generate_content_key($content, $options);

        // Check cache first
        $cached_result = $this->cache->get($cache_key);
        if ($cached_result !== null) {
            return $cached_result['data'];
        }

        try {
            // Generate metadata using AI
            $metadata = $this->client->generate_seo_metadata($content, $options);

            // Ensure user has configured their API key
            $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));

            if (!$user_has_api_key) {
                throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
            }

            // Cache the result
            $this->cache->set($cache_key, $metadata);

            // Log usage with actual model information and raw AI text (Content Brief pattern)
            $actual_model = $this->client ? $this->client->get_model() : null;
            $ai_text = $metadata['_ai_text'] ?? null;
            $this->log_ai_usage($user_id, 'SEO Metadata', $metadata['tokens_used'] ?? 0, $actual_model, $ai_text);

            // Remove AI text from returned data to keep it clean
            unset($metadata['_ai_text']);

            return $metadata;

        } catch (\Exception $e) {
            throw $e;
        }
    }

    /**
     * Generate an improved SEO title that addresses a specific suggestion.
     *
     * Used by the "Apply" action on title-related SEO score suggestions. Builds a
     * focused, best-practice title prompt and runs it through the configured
     * provider, reusing the same completion/extraction path as the content brief
     * generator so OpenAI, Claude and Gemini all parse consistently.
     *
     * @since 1.14.0
     *
     * @param string $content Post content for context.
     * @param array  $options {
     *     @type string $current_title  Current SEO title.
     *     @type string $target_keyword Focus keyword.
     *     @type string $content_type   Content type (blog_post, page, …).
     *     @type string $tone           Desired tone.
     *     @type string $suggestion     The suggestion the title must address.
     * }
     * @return array{title:string} The improved SEO title.
     * @throws \Exception If the AI client is unavailable or returns no title.
     */
    public function improve_seo_title(string $content, array $options = []): array {
        if (!$this->client) {
            $this->initialize_client();
            if (!$this->client) {
                throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
            }
        }

        // Ensure user has configured their API key.
        $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
        if (!$user_has_api_key) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        // Check rate limits.
        if (!$this->check_rate_limit()) {
            throw new \Exception('Rate limit exceeded. Please try again later.');
        }

        $current_title  = (string) ($options['current_title'] ?? '');
        $target_keyword = (string) ($options['target_keyword'] ?? '');
        $content_type   = (string) ($options['content_type'] ?? 'blog_post');
        $tone           = (string) ($options['tone'] ?? 'professional');
        $suggestion     = (string) ($options['suggestion'] ?? '');
        $language       = (string) ($options['language'] ?? '');

        // Cache identical requests (same content + inputs) to avoid duplicate calls.
        // Cap content server-side (mirror the frontend 5000-char trim) so a
        // direct REST caller can't force oversized prompt/cache/AI work.
        $content = mb_substr($content, 0, 5000);

        $cache_key = 'improve_title_' . md5($content . '|' . $current_title . '|' . $target_keyword . '|' . $content_type . '|' . $tone . '|' . $suggestion);
        $cached_result = $this->cache->get($cache_key);
        if ($cached_result !== null) {
            return $cached_result['data'] ?? $cached_result;
        }

        $user_id = get_current_user_id();
        $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';

        // Use ThinkRank's own validation word lists so the generated title passes
        // the same emotion/sentiment and power-word checks the scorer applies.
        $sentiment_words = SEOScoreCalculator::get_title_sentiment_words();
        $power_words = SEOScoreCalculator::get_title_power_words();

        // When the suggestion explicitly asks for an emotional/sentiment word we
        // strictly validate the result (and retry once) to guarantee it passes.
        $needs_sentiment = stripos($suggestion, 'sentiment') !== false || stripos($suggestion, 'emotional') !== false;

        $prompt = (new Prompt_Builder())->build_title_improvement_prompt(
            $content,
            $current_title,
            $target_keyword,
            $content_type,
            $tone,
            $suggestion,
            $provider,
            $sentiment_words,
            $power_words,
            $language
        );

        $generated = $this->request_title($prompt);
        $title = $generated['title'];
        $total_tokens = $generated['tokens'];
        $ai_text = $generated['ai_text'];
        $finish_reason = $generated['finish_reason'];

        // A reasoning model can still return an empty/truncated title on the
        // first pass; retry once before giving up so the "Apply" action reliably
        // produces a title.
        if ($title === '') {
            $retry = $this->request_title($prompt);
            $total_tokens += $retry['tokens'];
            if ($retry['ai_text'] !== '') {
                $ai_text = $retry['ai_text'];
            }
            $finish_reason = $retry['finish_reason'];
            if ($retry['title'] !== '') {
                $title = $retry['title'];
            }
        }

        // Guarantee the emotion/sentiment check passes: if it was required but the
        // title still lacks a listed word, retry once with a non-negotiable
        // instruction. If the retry also fails we keep the best title we have.
        if ($needs_sentiment && !$this->title_contains_word($title, $sentiment_words)) {
            $retry_prompt = $prompt . "\n\nIMPORTANT: Your previous attempt was rejected because the title did not contain a required word. The new title MUST include at least one of these exact words verbatim: " . implode(', ', $sentiment_words) . '.';
            $retry = $this->request_title($retry_prompt);
            $total_tokens += $retry['tokens'];
            if ($retry['ai_text'] !== '') {
                $ai_text = $retry['ai_text'];
            }
            $finish_reason = $retry['finish_reason'];
            if ($retry['title'] !== '' && $this->title_contains_word($retry['title'], $sentiment_words)) {
                $title = $retry['title'];
            }
        }

        if ($title === '') {
            // Nothing about a raw JSON-parse failure is visible to support
            // otherwise — log_ai_usage() below only runs on success, so a
            // failed attempt left no trace of what the model actually sent
            // back or why generation stopped.
            if (defined('WP_DEBUG') && WP_DEBUG) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
                error_log(sprintf(
                    '[ThinkRank] Title improvement failed to extract a title. finish_reason=%s ai_text=%s',
                    $finish_reason !== '' ? $finish_reason : '(none)',
                    mb_substr($ai_text, 0, 500)
                ));
            }
            throw new \Exception('The AI did not return a usable title. Please try again.');
        }

        // Log usage.
        $actual_model = $this->client ? $this->client->get_model() : null;
        $this->log_ai_usage($user_id, 'SEO Title Improvement', (int) $total_tokens, $actual_model, $ai_text);

        $result = ['title' => $title];
        $this->cache->set($cache_key, $result);

        return $result;
    }

    /**
     * Run a single title-generation request: call the provider, extract the
     * title text across provider response shapes, and clamp it to 60 characters.
     *
     * @param string $prompt The prompt to send.
     * @return array{title:string,ai_text:string,tokens:int}
     */
    private function request_title(string $prompt): array {
        // Larger budget so reasoning models (e.g. gpt-5-nano) don't spend the
        // whole allowance "thinking" and truncate the JSON before the title.
        $completion = $this->request_completion($prompt, 4096);
        $title = $this->extract_json_field($completion['ai_text'], 'title');

        // Safety net: enforce the 60-character maximum even if the model overruns.
        if (mb_strlen($title) > 60) {
            $title = rtrim(mb_substr($title, 0, 60));
        }

        return [
            'title' => $title,
            'ai_text' => $completion['ai_text'],
            'tokens' => $completion['tokens'],
            'finish_reason' => $completion['finish_reason'],
        ];
    }

    /**
     * Generate an improved meta description that addresses a specific suggestion.
     *
     * Guarantees ThinkRank's technical check passes (120-160 characters) and,
     * when the suggestion is about the focus keyword, that the keyword is present
     * — retrying once if the first attempt falls outside the constraints.
     *
     * @since 1.14.0
     *
     * @param string $content Post content for context.
     * @param array  $options {
     *     @type string $current_description Current meta description.
     *     @type string $target_keyword     Focus keyword.
     *     @type string $content_type        Content type.
     *     @type string $tone                Desired tone.
     *     @type string $suggestion          The suggestion to address.
     * }
     * @return array{description:string} The improved meta description.
     * @throws \Exception If the AI client is unavailable or returns nothing usable.
     */
    public function improve_meta_description(string $content, array $options = []): array {
        if (!$this->client) {
            $this->initialize_client();
            if (!$this->client) {
                throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
            }
        }

        $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
        if (!$user_has_api_key) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        if (!$this->check_rate_limit()) {
            throw new \Exception('Rate limit exceeded. Please try again later.');
        }

        $current_desc   = (string) ($options['current_description'] ?? '');
        $target_keyword = (string) ($options['target_keyword'] ?? '');
        $content_type   = (string) ($options['content_type'] ?? 'blog_post');
        $tone           = (string) ($options['tone'] ?? 'professional');
        $suggestion     = (string) ($options['suggestion'] ?? '');
        $language       = (string) ($options['language'] ?? '');

        // Cap content server-side (mirror the frontend 5000-char trim) so a
        // direct REST caller can't force oversized prompt/cache/AI work.
        $content = mb_substr($content, 0, 5000);

        $cache_key = 'improve_meta_' . md5($content . '|' . $current_desc . '|' . $target_keyword . '|' . $content_type . '|' . $tone . '|' . $suggestion);
        $cached_result = $this->cache->get($cache_key);
        if ($cached_result !== null) {
            return $cached_result['data'] ?? $cached_result;
        }

        $user_id = get_current_user_id();
        $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';

        // The keyword must appear when the suggestion is keyword-specific, or
        // whenever a focus keyword exists (the scorer rewards it either way).
        $needs_keyword = $target_keyword !== '';

        $build_prompt = fn() => (new Prompt_Builder())->build_meta_description_improvement_prompt(
            $content,
            $current_desc,
            $target_keyword,
            $content_type,
            $tone,
            $suggestion,
            $provider,
            $language
        );

        $valid = function (string $desc) use ($needs_keyword, $target_keyword): bool {
            $len = mb_strlen($desc);
            if ($len < 120 || $len > 160) {
                return false;
            }
            if ($needs_keyword && strpos(strtolower($desc), strtolower($target_keyword)) === false) {
                return false;
            }
            return true;
        };

        $prompt = $build_prompt();
        // Larger budget so reasoning models don't truncate the JSON before the
        // description (which surfaced as "could not produce a 120-160 character
        // meta description" on gpt-5-nano).
        $completion = $this->request_completion($prompt, 4096);
        $description = $this->extract_json_field($completion['ai_text'], 'description');
        $total_tokens = $completion['tokens'];
        $ai_text = $completion['ai_text'];

        // Retry once with explicit, measurable constraints if the first attempt
        // misses the mandatory length window or the required keyword.
        if (!$valid($description)) {
            $extra = "\n\nIMPORTANT: Your previous attempt did not meet the requirements. The description MUST be between 120 and 160 characters";
            if ($needs_keyword) {
                $extra .= " and MUST contain the exact phrase \"{$target_keyword}\"";
            }
            $extra .= '. Count the characters before answering.';
            $retry = $this->request_completion($prompt . $extra, 4096);
            $retry_desc = $this->extract_json_field($retry['ai_text'], 'description');
            $total_tokens += $retry['tokens'];
            if ($retry['ai_text'] !== '') {
                $ai_text = $retry['ai_text'];
            }
            // Prefer a valid candidate; otherwise keep the longer non-empty one so
            // the clamp below can bring an over-long description into range.
            if ($valid($retry_desc)) {
                $description = $retry_desc;
            } elseif ($description === '') {
                $description = $retry_desc;
            } elseif (!$valid($description) && mb_strlen($retry_desc) > mb_strlen($description)) {
                $description = $retry_desc;
            }
        }

        // Hard safety net: guarantee the 160-character ceiling by trimming at a
        // word boundary, so the scorer's 120-160 technical check passes even if a
        // "thinking" model overran the limit.
        $description = $this->clamp_meta_description($description);

        if ($description === '' || mb_strlen($description) < 120) {
            throw new \Exception('The AI could not produce a 120-160 character meta description. Please try again.');
        }

        $actual_model = $this->client ? $this->client->get_model() : null;
        $this->log_ai_usage($user_id, 'SEO Meta Description', (int) $total_tokens, $actual_model, $ai_text);

        $result = ['description' => $description];
        $this->cache->set($cache_key, $result);

        return $result;
    }

    /**
     * Explain a single SEO score suggestion in plain, post-specific language.
     *
     * Powers the "Explain with AI" copilot action on each suggestion. Unlike the
     * improve_* methods this does not modify content — it returns a short,
     * context-aware explanation of why the suggestion matters for this post and
     * how to resolve it, so the author understands the fix before applying it.
     *
     * @since 1.18.0
     *
     * @param string $content Post content for context.
     * @param array  $options {
     *     @type string $suggestion     The suggestion to explain (required).
     *     @type string $title          Post/SEO title for context.
     *     @type string $target_keyword Focus keyword.
     *     @type string $content_type   Content type (blog_post, page, …).
     * }
     * @return array{explanation:string} The plain-language explanation.
     * @throws \Exception If the AI client is unavailable or returns nothing usable.
     */
    public function explain_seo_suggestion(string $content, array $options = []): array {
        $this->ensure_ready_for_ai();

        // Cap content server-side so a direct REST caller cannot bypass the
        // frontend's 5000-character trim and force oversized prompt building,
        // cache hashing, and expensive AI calls/retries.
        $content = mb_substr($content, 0, 5000);

        $suggestion = trim((string) ($options['suggestion'] ?? ''));
        if ($suggestion === '') {
            throw new \Exception('A suggestion is required to generate an explanation.');
        }
        $title          = (string) ($options['title'] ?? '');
        $target_keyword = (string) ($options['target_keyword'] ?? '');
        $content_type   = (string) ($options['content_type'] ?? 'blog_post');

        $cache_key = 'explain_' . md5($suggestion . '|' . $content . '|' . $title . '|' . $target_keyword . '|' . $content_type);
        $cached = $this->cache->get($cache_key);
        if ($cached !== null) {
            return $cached['data'] ?? $cached;
        }

        $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
        $prompt = (new Prompt_Builder())->build_suggestion_explanation_prompt($suggestion, $content, $title, $target_keyword, $content_type, $provider);

        // Give reasoning models (e.g. gpt-5-nano) enough headroom that they don't
        // burn the whole budget "thinking" and truncate the JSON before the
        // closing brace, and retry once if the first attempt yields nothing
        // parseable — mirrors the resilience of the keyword-paragraph path.
        $explanation = '';
        $tokens_used = 0;
        $ai_text = '';
        for ($attempt = 0; $attempt < 2; $attempt++) {
            $completion = $this->request_completion($prompt, 4096);
            $tokens_used += (int) $completion['tokens'];
            $ai_text = $completion['ai_text'];
            $candidate = $this->extract_json_field($completion['ai_text'], 'explanation');
            if ($candidate !== '') {
                $explanation = $candidate;
                break;
            }
        }

        if ($explanation === '') {
            throw new \Exception('The AI did not return an explanation. Please try again.');
        }

        $actual_model = $this->client ? $this->client->get_model() : null;
        $this->log_ai_usage(get_current_user_id(), 'SEO Suggestion Explanation', (int) $tokens_used, $actual_model, $ai_text);

        $result = ['explanation' => $explanation];
        $this->cache->set($cache_key, $result);

        return $result;
    }

    /**
     * Generate a targeted content fragment that adds one authoritative external
     * dofollow link, so the scorer's external-dofollow-link check passes.
     *
     * @since 1.14.0
     *
     * @param string $content Post content for context.
     * @param array  $options { @type string $target_keyword; @type string $content_type; }
     * @return array{html:string,url:string,anchor:string} HTML paragraph to append.
     * @throws \Exception If the AI client is unavailable or returns no valid link.
     */
    public function generate_dofollow_link(string $content, array $options = []): array {
        $this->ensure_ready_for_ai();

        $target_keyword = (string) ($options['target_keyword'] ?? '');
        $content_type   = (string) ($options['content_type'] ?? 'blog_post');

        // Cap content server-side (mirror the frontend 5000-char trim) so a
        // direct REST caller can't force oversized prompt/cache/AI work.
        $content = mb_substr($content, 0, 5000);

        $cache_key = 'dofollow_' . md5($content . '|' . $target_keyword . '|' . $content_type);
        $cached = $this->cache->get($cache_key);
        if ($cached !== null) {
            return $cached['data'] ?? $cached;
        }

        $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
        $prompt = (new Prompt_Builder())->build_dofollow_link_prompt($content, $target_keyword, $content_type, $provider);

        // Larger budget + one retry: reasoning models can truncate the JSON and
        // yield no URL, which surfaced as "did not return a valid external
        // source" on the first attempt.
        $data = [];
        $tokens_used = 0;
        $ai_text = '';
        for ($attempt = 0; $attempt < 2; $attempt++) {
            $completion = $this->request_completion($prompt, 4096);
            $tokens_used += (int) $completion['tokens'];
            $ai_text = $completion['ai_text'];
            $candidate = $this->extract_json_object($completion['ai_text']);
            if (is_array($candidate) && !empty($candidate['url'])) {
                $data = $candidate;
                break;
            }
        }

        $url = isset($data['url']) ? esc_url_raw(trim((string) $data['url'])) : '';
        $anchor = isset($data['anchor']) ? sanitize_text_field((string) $data['anchor']) : '';
        $sentence = isset($data['sentence']) ? sanitize_text_field((string) $data['sentence']) : '';

        // Validate: must be a real external http(s) URL pointing off-site.
        $site_host = wp_parse_url(get_site_url(), PHP_URL_HOST);
        $link_host = $url !== '' ? wp_parse_url($url, PHP_URL_HOST) : '';
        $is_external = $url !== '' && preg_match('#^https?://#i', $url) && $link_host && strcasecmp($link_host, (string) $site_host) !== 0;
        if (!$is_external) {
            throw new \Exception('The AI did not return a valid external source. Please try again.');
        }
        if ($anchor === '') {
            $anchor = $link_host;
        }
        if ($sentence === '') {
            $sentence = sprintf('For more on this topic, see %s.', $anchor);
        }

        // Build a dofollow anchor (no rel=nofollow) and weave it into the
        // sentence by linking the anchor text; append it if the anchor phrase is
        // not present.
        $link = sprintf('<a href="%s">%s</a>', esc_url($url), esc_html($anchor));
        if (stripos($sentence, $anchor) !== false) {
            $linked = preg_replace('/' . preg_quote($anchor, '/') . '/i', $link, $sentence, 1);
        } else {
            $linked = rtrim($sentence, '.') . ' (' . $link . ').';
        }
        $html = '<p>' . $linked . '</p>';

        $actual_model = $this->client ? $this->client->get_model() : null;
        $this->log_ai_usage(get_current_user_id(), 'SEO Dofollow Link', (int) $tokens_used, $actual_model, $ai_text);

        $result = ['html' => $html, 'url' => $url, 'anchor' => $anchor];
        $this->cache->set($cache_key, $result);

        return $result;
    }

    /**
     * Generate a short, relevant closing paragraph that uses the focus keyword
     * enough times to lift keyword density into the scorer's healthy band
     * (0.5%-2.5%), returned as an HTML paragraph to append to the content.
     *
     * @since 1.14.0
     *
     * @param string $content Post content for context.
     * @param array  $options {
     *     @type string $target_keyword;
     *     @type string $content_type;
     *     @type string $tone;
     *     @type int    $word_count    Current document word count.
     *     @type int    $keyword_count Current focus-keyword occurrences.
     * }
     * @return array{html:string,mentions:int} HTML paragraph to append.
     * @throws \Exception If the AI client is unavailable or returns nothing usable.
     */
    public function generate_keyword_paragraph(string $content, array $options = []): array {
        $this->ensure_ready_for_ai();

        $target_keyword = trim((string) ($options['target_keyword'] ?? ''));
        if ($target_keyword === '') {
            throw new \Exception('A focus keyword is required to improve keyword density.');
        }
        $content_type  = (string) ($options['content_type'] ?? 'blog_post');
        $tone          = (string) ($options['tone'] ?? 'professional');
        $word_count    = max(0, (int) ($options['word_count'] ?? 0));
        $keyword_count = max(0, (int) ($options['keyword_count'] ?? 0));

        // Size the closing section to land just above the 0.5% floor. Solving
        // (kw + m) / (words + W) >= target for a section that uses ~14 words per
        // keyword mention (W = 14m) keeps the writing readable rather than
        // stuffed. Cap mentions so a very long, sparse article doesn't demand an
        // absurd block — in that case one pass improves density without fully
        // resolving it, which the caller surfaces honestly.
        // Target a bit above the 0.5% floor and assume a tight ~11 words per
        // mention when sizing the request, because models tend to under-deliver
        // mentions and over-write length — both of which dilute density. The cap
        // keeps very long, sparse posts from demanding an absurd block; those may
        // still need a second pass, which the caller surfaces honestly.
        $target_density = 0.0065;
        $words_per_mention = 11;
        $denom_factor = 1 - ($target_density * $words_per_mention); // ~0.928
        $needed = $denom_factor > 0
            ? ($target_density * $word_count - $keyword_count) / $denom_factor
            : 4;
        $mentions = (int) max(3, min(24, ceil($needed)));
        $para_words = max(90, $mentions * $words_per_mention);

        // Cap content server-side (mirror the frontend 5000-char trim) so a
        // direct REST caller can't force oversized prompt/cache/AI work.
        $content = mb_substr($content, 0, 5000);

        $cache_key = 'kw_para_' . md5($content . '|' . $target_keyword . '|' . $content_type . '|' . $tone . '|' . $mentions . '|' . $para_words);
        $cached = $this->cache->get($cache_key);
        if ($cached !== null) {
            return $cached['data'] ?? $cached;
        }

        $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
        $prompt = (new Prompt_Builder())->build_keyword_paragraph_prompt($content, $target_keyword, $content_type, $tone, $mentions, $provider, $para_words);

        // Bigger token budget: the section is long and thinking models burn
        // output tokens reasoning before writing the JSON. Generation can be
        // truncated intermittently, yielding a stub — validate and retry once so
        // we never apply (or cache) a degenerate paragraph.
        $paragraph = '';
        $tokens_used = 0;
        $ai_text = '';
        for ($attempt = 0; $attempt < 2; $attempt++) {
            $completion = $this->request_completion($prompt, 4096);
            $tokens_used += (int) $completion['tokens'];
            $ai_text = $completion['ai_text'];
            $candidate = $this->extract_json_field($completion['ai_text'], 'paragraph');
            if (str_word_count(wp_strip_all_tags($candidate)) >= 40) {
                $paragraph = $candidate;
                break;
            }
        }
        if ($paragraph === '') {
            throw new \Exception('The AI did not return a usable paragraph. Please try again.');
        }

        // wp_kses keeps it to safe inline markup; wrap as a paragraph block.
        $paragraph = wp_kses($paragraph, ['a' => ['href' => [], 'title' => []], 'strong' => [], 'em' => []]);
        $html = '<p>' . $paragraph . '</p>';

        // Report the density this addition achieves so the UI can tell the user
        // whether the check is now satisfied or needs another pass.
        $added_words = str_word_count(wp_strip_all_tags($paragraph));
        $added_mentions = substr_count(strtolower(wp_strip_all_tags($paragraph)), strtolower($target_keyword));
        $new_density = ($word_count + $added_words) > 0
            ? (($keyword_count + $added_mentions) / ($word_count + $added_words)) * 100
            : 0.0;
        $resolves = $new_density >= 0.5 && $new_density <= 2.5;

        $actual_model = $this->client ? $this->client->get_model() : null;
        $this->log_ai_usage(get_current_user_id(), 'SEO Keyword Paragraph', $tokens_used, $actual_model, $ai_text);

        $result = [
            'html' => $html,
            'mentions' => $added_mentions,
            'new_density' => round($new_density, 2),
            'resolves' => $resolves,
        ];
        $this->cache->set($cache_key, $result);

        return $result;
    }

    /**
     * Shared guard for the lightweight AI helpers: ensure a client is available,
     * the user has an API key, and the per-minute rate limit is not exceeded.
     *
     * @throws \Exception When any precondition fails.
     */
    private function ensure_ready_for_ai(): void {
        if (!$this->client) {
            $this->initialize_client();
            if (!$this->client) {
                throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
            }
        }
        $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
        if (!$user_has_api_key) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }
        if (!$this->check_rate_limit()) {
            throw new \Exception('Rate limit exceeded. Please try again later.');
        }
    }

    /**
     * Decode the first JSON object found in an AI response.
     *
     * @param string $ai_text Raw AI text.
     * @return array|null Decoded object, or null if none parses.
     */
    private function extract_json_object(string $ai_text): ?array {
        $json_start = strpos($ai_text, '{');
        $json_end = strrpos($ai_text, '}');
        if ($json_start === false || $json_end === false || $json_end <= $json_start) {
            return null;
        }
        $decoded = json_decode(substr($ai_text, $json_start, $json_end - $json_start + 1), true);
        return is_array($decoded) ? $decoded : null;
    }

    /**
     * Send one prompt to the configured provider and return the raw text plus
     * token usage, normalising across provider response shapes (mirrors the
     * content brief generator's multi-provider handling).
     *
     * @param string $prompt The prompt to send.
     * @return array{ai_text:string,tokens:int}
     */
    /**
     * Detect a provider-side refusal or content-policy block and fail with
     * the real reason. Each provider signals these differently, and none of
     * the signals set the content field the extraction chain looks for — left
     * unchecked they read as an empty/unusable result with no explanation of
     * why, and every caller here retries an empty result once, which just
     * repeats the same refusal at the cost of more tokens.
     *
     * @param array $response Raw response from the AI client.
     * @throws \Exception If the response is a refusal or policy block.
     */
    private function guard_against_refusal(array $response): void {
        // --- OpenAI (Chat Completions) ---
        // A structured refusal is HTTP 200 with message.content=null and the
        // stated reason carried in message.refusal.
        if (isset($response['choices'][0]['message'])) {
            $message = $response['choices'][0]['message'];
            $finish  = (string) ($response['choices'][0]['finish_reason'] ?? '');

            if (!empty($message['refusal'])) {
                throw new \Exception(esc_html('The AI declined this request: ' . (string) $message['refusal']));
            }
            if ('content_filter' === $finish) {
                throw new \Exception('The AI blocked this request under its content policy. Try different wording.');
            }
        }

        // --- Claude (Messages) ---
        if (isset($response['stop_reason']) && 'refusal' === (string) $response['stop_reason']) {
            throw new \Exception('The AI declined this request. Try different wording.');
        }

        // --- Gemini ---
        // A prompt rejected outright returns no candidate at all, only
        // promptFeedback.blockReason; a candidate can also finish on SAFETY or
        // PROHIBITED_CONTENT.
        $block_reason = (string) ($response['promptFeedback']['blockReason'] ?? '');
        if ('' !== $block_reason) {
            throw new \Exception(esc_html(sprintf('The AI blocked this request under its content policy (%s). Try different wording.', $block_reason)));
        }
        $gemini_finish = (string) ($response['candidates'][0]['finishReason'] ?? '');
        if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
            throw new \Exception('The AI blocked this request under its content policy. Try different wording.');
        }
    }

    private function request_completion(string $prompt, int $max_tokens = 2048, array $options = []): array {
        // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
        // before emitting text, so the cap must cover both the reasoning and the
        // visible JSON. Longer outputs (paragraphs) need a bigger budget. It's
        // only a ceiling — short replies cost no more.
        // Extra options (e.g. reasoning_effort) pass through; every client
        // cherry-picks the keys it understands and ignores the rest.
        $response = $this->client->generate_completion($prompt, array_merge($options, [
            'max_tokens' => $max_tokens,
            'temperature' => 0.4,
        ]));

        // Fail fast on a genuine refusal/policy block instead of retrying the
        // same prompt (every caller retries on an empty result) and burning
        // more tokens on a request the model has already declined. Truncation
        // (finish_reason length/max_tokens) is deliberately NOT treated as a
        // hard failure here — callers' existing empty-result retries already
        // recover from that, and a retry can succeed where the first attempt
        // spent its budget on hidden reasoning.
        $this->guard_against_refusal($response);

        $ai_text = '';
        if (isset($response['choices'][0]['message']['content'])) {
            $ai_text = is_array($response['choices'][0]['message']['content'])
                ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
                : (string) $response['choices'][0]['message']['content'];
        } elseif (isset($response['content'][0]['text'])) {
            $ai_text = (string) $response['content'][0]['text'];
        } elseif (isset($response['candidates'][0]['content']['parts'][0]['text'])) {
            $ai_text = (string) $response['candidates'][0]['content']['parts'][0]['text'];
        } elseif (isset($response['content']) && is_string($response['content'])) {
            $ai_text = $response['content'];
        }

        $tokens = $response['usage']['total_tokens']
            ?? $response['usage']['output_tokens']
            ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);

        // Diagnostics for callers that must explain an empty answer: why
        // generation stopped, and how much of the
        // completion budget hidden reasoning consumed (OpenAI reasoning models).
        // All three provider shapes are read — Gemini reports the stop reason
        // per candidate, so without that arm the diagnostic was always blank
        // for exactly the provider whose truncation it exists to explain.
        $finish_reason    = (string) ($response['choices'][0]['finish_reason']
            ?? ($response['stop_reason']
            ?? ($response['candidates'][0]['finishReason'] ?? '')));
        $reasoning_tokens = (int) ($response['usage']['completion_tokens_details']['reasoning_tokens'] ?? 0);

        return [
            'ai_text'          => $ai_text,
            'tokens'           => (int) $tokens,
            'finish_reason'    => $finish_reason,
            'reasoning_tokens' => $reasoning_tokens,
        ];
    }

    /**
     * Trim a meta description to at most 160 characters at a word boundary,
     * preserving sentence-ish endings and avoiding broken words. Descriptions of
     * 160 characters or fewer are returned unchanged.
     *
     * @param string $desc Meta description.
     * @return string Description clamped to <= 160 characters.
     */
    private function clamp_meta_description(string $desc): string {
        $desc = trim($desc);
        if (mb_strlen($desc) <= 160) {
            return $desc;
        }

        $cut = mb_substr($desc, 0, 160);
        $last_space = mb_strrpos($cut, ' ');
        // Only back off to the last space when doing so keeps us at/above 120.
        if ($last_space !== false && $last_space >= 120) {
            $cut = mb_substr($cut, 0, $last_space);
        }

        return rtrim($cut, " \t\n\r\0\x0B,;:-");
    }

    /**
     * Whether a title contains any of the given words, using the same
     * case-insensitive substring match the scorer's title checks use.
     *
     * @param string   $title Title to test.
     * @param string[] $words Words to look for.
     * @return bool
     */
    private function title_contains_word(string $title, array $words): bool {
        $title_lower = strtolower($title);
        foreach ($words as $word) {
            if ($word !== '' && strpos($title_lower, strtolower($word)) !== false) {
                return true;
            }
        }
        return false;
    }

    /**
     * Pull a named string field out of an AI response, tolerating both JSON and
     * plain-text replies.
     *
     * @param string $ai_text Raw AI text.
     * @param string $field   JSON field to read (e.g. 'title', 'description').
     * @return string Sanitized value (without surrounding quotes), or '' on failure.
     */
    private function extract_json_field(string $ai_text, string $field): string {
        $ai_text = trim($ai_text);
        if ($ai_text === '') {
            return '';
        }

        // Prefer a JSON object with the requested field.
        $json_start = strpos($ai_text, '{');
        $json_end = strrpos($ai_text, '}');
        if ($json_start !== false && $json_end !== false && $json_end > $json_start) {
            $decoded = json_decode(substr($ai_text, $json_start, $json_end - $json_start + 1), true);
            if (is_array($decoded) && !empty($decoded[$field])) {
                return sanitize_text_field(trim((string) $decoded[$field], " \t\n\r\0\x0B\"'"));
            }
        }

        // Fall back to the first non-empty line, stripping wrapping quotes.
        $first_line = strtok($ai_text, "\n");
        return sanitize_text_field(trim((string) $first_line, " \t\n\r\0\x0B\"'"));
    }

    /**
     * Analyze content for SEO optimization
     *
     * @param string $content Content to analyze
     * @param array $metadata Existing metadata
     * @return array Analysis results
     * @throws \Exception If analysis fails
     */
    public function analyze_content(string $content, array $metadata = []): array {
        if (!$this->client) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        $user_id = get_current_user_id();

        // Ensure user has configured their API key
        $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));

        if (!$user_has_api_key) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        // Check rate limits
        if (!$this->check_rate_limit($user_id, 'content_analysis')) {
            throw new \Exception('Rate limit exceeded. Please try again later.');
        }

        // Check cache first
        $cache_key = 'content_analysis_' . md5($content . wp_json_encode($metadata));
        $cached_result = $this->cache->get($cache_key);
        if ($cached_result) {
            return $cached_result['data'] ?? $cached_result;
        }

        try {
            // Analyze content using AI
            $analysis = $this->client->analyze_content($content, $metadata);

            // Cache the result
            $this->cache->set($cache_key, $analysis);

            // Log usage with actual model information and raw AI text (Content Brief pattern)
            $actual_model = $this->client ? $this->client->get_model() : null;
            $ai_text = $analysis['_ai_text'] ?? null;
            $this->log_ai_usage($user_id, 'Content Analysis', $analysis['tokens_used'] ?? 0, $actual_model, $ai_text);

            // Remove AI text from returned data to keep it clean
            unset($analysis['_ai_text']);

            return $analysis;

        } catch (\Exception $e) {
            throw $e;
        }
    }

    /**
     * Test API connection
     *
     * @return array Test result
     */
    public function test_api_connection(): array {
        if (!$this->client) {
            return [
                'success' => false,
                'message' => $this->get_client_unavailable_message(),
            ];
        }

        try {
            $success = $this->client->test_connection();

            return [
                'success' => $success,
                'message' => $success
                    ? 'API connection successful!'
                    : 'API connection failed. Please check your API key.',
            ];

        } catch (\Exception $e) {
            return [
                'success' => false,
                'message' => 'Connection test failed: ' . $e->getMessage(),
            ];
        }
    }

    /**
     * Optimize site identity using AI
     *
     * @since 1.0.0
     *
     * @param array $site_data Site data to optimize
     * @param array $options Optimization options
     * @return array Optimization results
     * @throws \Exception If optimization fails
     */
    public function optimize_site_identity(array $site_data, array $options = []): array {
        // Validate input
        if (empty($site_data)) {
            throw new \Exception('Site data cannot be empty');
        }

        $user_id = get_current_user_id();

        // Ensure user has configured their API key
        $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));

        if (!$user_has_api_key) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        // Generate cache key using existing pattern
        $cache_key = 'site_identity_' . md5(wp_json_encode($site_data) . wp_json_encode($options)) . '_' . $user_id;

        // Check existing cache infrastructure
        // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
        // before inspecting — checking optimized_data on the wrapped array
        // never matches and the cache would never hit.
        $cached_result = $this->cache->get($cache_key);
        $cached_result = $cached_result['data'] ?? $cached_result;
        if (!empty($cached_result['optimized_data'])) {
            return $cached_result;
        }

        // Check rate limiting
        if (!$this->check_rate_limit()) {
            throw new \Exception('Rate limit exceeded for AI optimization requests.');
        }

        // Get AI client
        $client = $this->get_client();

        if (!$client) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        // Perform AI optimization
        $optimization_results = $client->optimize_site_identity($site_data, $options);

        // Validate that we got meaningful results
        if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
            throw new \Exception('AI optimization returned empty results. Please try again.');
        }

        // Add metadata
        $optimization_results['ai_model'] = $client->get_model();
        $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
        $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
        $optimization_results['user_id'] = $user_id;

        // Cache the results (24 hours)
        $this->cache->set($cache_key, $optimization_results, 86400);

        // Record usage with actual model information and raw AI text (Content Brief pattern)
        $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
        $ai_text = $optimization_results['_ai_text'] ?? null;
        $this->log_ai_usage($user_id, 'Site Identity Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);

        // Remove AI text from returned data to keep it clean
        unset($optimization_results['_ai_text']);

        return $optimization_results;
    }

    /**
     * Optimize LLMs.txt content using AI
     *
     * @since 1.0.0
     *
     * @param array $website_data Website data to optimize
     * @param array $options Optimization options
     * @return array Optimization results
     * @throws \Exception If optimization fails
     */
    public function optimize_llms_txt(array $website_data, array $options = []): array {
        // Validate input
        if (empty($website_data)) {
            throw new \Exception('Website data cannot be empty');
        }

        $user_id = get_current_user_id();

        // Ensure user has configured their API key
        $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));

        if (!$user_has_api_key) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        // Generate cache key
        $cache_key = 'llms_txt_' . md5(wp_json_encode($website_data) . wp_json_encode($options)) . '_' . $user_id;

        // Check cache first
        // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
        // before inspecting — checking optimized_data on the wrapped array
        // never matches and the cache would never hit.
        $cached_result = $this->cache->get($cache_key);
        $cached_result = $cached_result['data'] ?? $cached_result;
        if (!empty($cached_result['optimized_data'])) {
            return $cached_result;
        }

        // Check rate limiting
        if (!$this->check_rate_limit()) {
            throw new \Exception('Rate limit exceeded for AI optimization requests.');
        }

        // Get AI client
        $client = $this->get_client();

        if (!$client) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        // Perform AI optimization
        $optimization_results = $client->optimize_llms_txt($website_data, $options);

        // Validate that we got meaningful results
        if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
            throw new \Exception('AI optimization returned empty results. Please try again.');
        }

        // Add metadata
        $optimization_results['ai_model'] = $client->get_model();
        $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
        $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
        $optimization_results['user_id'] = $user_id;

        // Cache the results (24 hours)
        $this->cache->set($cache_key, $optimization_results, 86400);

        // Record usage with actual model information and raw AI text (Content Brief pattern)
        $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
        $ai_text = $optimization_results['_ai_text'] ?? null;
        $this->log_ai_usage($user_id, 'LLMs.txt Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);

        // Remove AI text from returned data to keep it clean
        unset($optimization_results['_ai_text']);

        return $optimization_results;
    }

    /**
     * Get available AI providers
     *
     * @return array Available providers
     */
    public function get_available_providers(): array {
        return [
            'openai' => [
                'name' => 'OpenAI',
                'description' => 'GPT‑5 series and GPT‑4o',
                'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
                'requires_key' => true,
            ],
            'claude' => [
                // The vendor, not the model family: the other three entries name
                // vendors, and a family name goes stale on every rename (#572).
                'name' => 'Anthropic',
                'description' => 'Claude Opus 5, Opus 4.8, Sonnet 5, and Haiku 4.5',
                'models' => ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5'],
                'requires_key' => true,
            ],
            'gemini' => [
                'name' => 'Google Gemini',
                'description' => 'Gemini 3.x models',
                // 2.5 Pro / 2.5 Flash-Lite retire in Oct 2026 and 3.1 Pro only
                // ships under its -preview id, so none of the three belong in a
                // list users pick from (#572).
                'models' => ['gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-3.1-pro-preview'],
                'requires_key' => true,
            ],
            'openrouter' => [
                'name' => 'OpenRouter',
                'description' => 'Unified access to many models via one key',
                // claude-3.5-sonnet is retired (Claude_Client::normalize_model
                // already self-heals it on the direct path) and
                // gemini-2.0-flash-001 was shut down on 1 Jun 2026 (#572).
                'models' => ['openai/gpt-4o-mini', 'anthropic/claude-sonnet-5', 'google/gemini-3.5-flash', 'meta-llama/llama-3.3-70b-instruct', 'deepseek/deepseek-chat'],
                'requires_key' => true,
            ],
        ];
    }

    /**
     * Get current provider status
     *
     * @return array Provider status
     */
    public function get_provider_status(): array {
        $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
        // With no provider chosen there is no "<provider>_api_key" to read;
        // asking for '_api_key' would be a nonsense lookup.
        $api_key = Settings::AI_PROVIDER_NONE === $provider
            ? ''
            : $this->settings->get($provider . '_api_key');

        return [
            'provider' => $provider,
            'configured' => !empty($api_key),
            'connected' => $this->client !== null,
        ];
    }

    /**
     * AJAX handler for generating metadata
     *
     * @return void
     */
    public function ajax_generate_metadata(): void {
        // Verify nonce
        $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
        if (!wp_verify_nonce($nonce, 'thinkrank_ai_nonce')) {
            wp_die('Security check failed');
        }

        // Check permissions
        if (!current_user_can('edit_posts')) {
            wp_die('Insufficient permissions');
        }

        $content = sanitize_textarea_field(wp_unslash($_POST['content'] ?? ''));
        $options = [
            'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
            'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
            'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
        ];

        try {
            $metadata = $this->generate_seo_metadata($content, $options);

            wp_send_json_success([
                'metadata' => $metadata,
                'message' => __('SEO metadata generated successfully!', 'thinkrank'),
            ]);

        } catch (\Exception $e) {
            wp_send_json_error([
                'message' => $e->getMessage(),
            ]);
        }
    }

    /**
     * AJAX handler for testing API connection
     *
     * @return void
     */
    public function ajax_test_connection(): void {
        // Verify nonce
        $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
        if (!wp_verify_nonce($nonce, 'thinkrank_ai_nonce')) {
            wp_die('Security check failed');
        }

        // Check permissions
        if (!current_user_can('manage_options')) {
            wp_die('Insufficient permissions');
        }

        $result = $this->test_api_connection();

        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            wp_send_json_error($result);
        }
    }

    /**
     * Check rate limits.
     *
     * Backed by a per-minute transient counter so the limit is enforced across
     * requests. A fresh Manager is constructed on every AJAX/REST call, so the
     * previous in-memory array always started empty and never limited anything —
     * letting an edit_posts user loop the metadata AJAX and drive unbounded paid
     * AI-provider spend.
     *
     * @param int|null $user_id Optional user id (defaults to the current user).
     * @param string   $context Rate-limit bucket (keeps distinct flows separate).
     * @return bool True if within limits.
     */
    private function check_rate_limit(?int $user_id = null, string $context = 'ai'): bool {
        $user_id = $user_id ?? get_current_user_id();
        $max_requests = (int) $this->settings->get('max_requests_per_minute', 10);

        // A non-positive limit means "unlimited".
        if ($max_requests <= 0) {
            return true;
        }

        // Counter is keyed to the current wall-clock minute; the transient TTL
        // lets the window roll over on its own.
        $minute_key = "thinkrank_ai_rate_{$context}_{$user_id}_" . floor(time() / MINUTE_IN_SECONDS);
        $attempts = (int) get_transient($minute_key);

        if ($attempts >= $max_requests) {
            return false;
        }

        set_transient($minute_key, $attempts + 1, MINUTE_IN_SECONDS);

        return true;
    }



    /**
     * Log AI usage with actual model information
     *
     * @param int $user_id User ID
     * @param string $action Action performed
     * @param int $tokens_used Tokens consumed
     * @param string|null $actual_model Actual model used (from AI response)
     * @param string|null $raw_response Raw AI response for debugging
     * @return void
     */
    private function log_ai_usage(int $user_id, string $action, int $tokens_used, ?string $actual_model = null, ?string $raw_response = null): void {
        global $wpdb;

        $table_name = $wpdb->prefix . 'thinkrank_ai_usage';

        // Prepare metadata with actual model information and raw response
        $metadata = [];
        if ($actual_model) {
            $metadata['actual_model'] = $actual_model;
        }
        if ($raw_response) {
            $metadata['raw_response'] = $raw_response;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage logging requires direct database access
        $wpdb->insert(
            $table_name,
            [
                'user_id' => $user_id,
                'action' => $action,
                'tokens_used' => $tokens_used,
                'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
                'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
                'created_at' => current_time('mysql'),
            ],
            ['%d', '%s', '%d', '%s', '%s', '%s']
        );

        /**
         * Fires after an AI usage row is recorded.
         *
         * Analytics listens to drop its cached overview, so the Usages page
         * reflects this action immediately instead of after the 600s TTL.
         *
         * @since 2.2.1
         *
         * @param int $user_id User the usage was recorded against.
         */
        do_action('thinkrank_ai_usage_logged', $user_id);
    }

    /**
     * Cleanup expired cache entries
     *
     * @return void
     */
    public function cleanup_cache(): void {
        $this->cache->clean_expired();
    }

    /**
     * Optimize homepage meta content using AI (copying Site Identity pattern exactly)
     *
     * @since 1.0.0
     *
     * @param array $content_data Meta content data to optimize
     * @param array $options Optimization options
     * @return array Optimization results
     * @throws \Exception If optimization fails
     */
    public function optimize_homepage_meta(array $content_data, array $options = []): array {
        // Validate input
        if (empty($content_data)) {
            throw new \Exception('Content data cannot be empty');
        }

        $user_id = get_current_user_id();

        // Ensure user has configured their API key (copying Site Identity pattern)
        $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));

        if (!$user_has_api_key) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        // Generate cache key using existing pattern
        $cache_key = 'homepage_meta_' . md5(wp_json_encode($content_data) . wp_json_encode($options)) . '_' . $user_id;

        // Check existing cache infrastructure
        // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
        // before inspecting — checking optimized_data on the wrapped array
        // never matches and the cache would never hit.
        $cached_result = $this->cache->get($cache_key);
        $cached_result = $cached_result['data'] ?? $cached_result;
        if (!empty($cached_result['optimized_data'])) {
            return $cached_result;
        }

        // Check rate limiting
        if (!$this->check_rate_limit()) {
            throw new \Exception('Rate limit exceeded for AI optimization requests.');
        }

        // Get AI client
        $client = $this->get_client();

        if (!$client) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        // Perform AI optimization
        $optimization_results = $client->optimize_homepage_meta($content_data, $options);

        // Validate that we got meaningful results
        if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
            throw new \Exception('AI optimization returned empty results. Please try again.');
        }

        // Add metadata
        $optimization_results['ai_model'] = $client->get_model();
        $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
        $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
        $optimization_results['user_id'] = $user_id;

        // Cache the results (24 hours)
        $this->cache->set($cache_key, $optimization_results, 86400);

        // Record usage with actual model information and raw AI text (Content Brief pattern)
        $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
        $ai_text = $optimization_results['_ai_text'] ?? null;
        $this->log_ai_usage($user_id, 'Homepage Meta Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);

        // Remove AI text from returned data to keep it clean
        unset($optimization_results['_ai_text']);

        return $optimization_results;
    }

    /**
     * Optimize homepage hero content using AI (copying Site Identity pattern exactly)
     *
     * @since 1.0.0
     *
     * @param array $hero_data Hero content data to optimize
     * @param array $options Optimization options
     * @return array Optimization results
     * @throws \Exception If optimization fails
     */
    public function optimize_homepage_hero(array $hero_data, array $options = []): array {
        // Validate input
        if (empty($hero_data)) {
            throw new \Exception('Hero data cannot be empty');
        }

        $user_id = get_current_user_id();

        // Ensure user has configured their API key (copying Site Identity pattern)
        $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));

        if (!$user_has_api_key) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        // Generate cache key using existing pattern
        $cache_key = 'homepage_hero_' . md5(wp_json_encode($hero_data) . wp_json_encode($options)) . '_' . $user_id;

        // Check existing cache infrastructure
        // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
        // before inspecting — checking optimized_data on the wrapped array
        // never matches and the cache would never hit.
        $cached_result = $this->cache->get($cache_key);
        $cached_result = $cached_result['data'] ?? $cached_result;
        if (!empty($cached_result['optimized_data'])) {
            return $cached_result;
        }

        // Check rate limiting
        if (!$this->check_rate_limit()) {
            throw new \Exception('Rate limit exceeded for AI optimization requests.');
        }

        // Get AI client
        $client = $this->get_client();

        if (!$client) {
            throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
        }

        // Perform AI optimization
        $optimization_results = $client->optimize_homepage_hero($hero_data, $options);

        // Validate that we got meaningful results
        if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
            throw new \Exception('AI optimization returned empty results. Please try again.');
        }

        // Add metadata
        $optimization_results['ai_model'] = $client->get_model();
        $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
        $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
        $optimization_results['user_id'] = $user_id;

        // Cache the results (24 hours)
        $this->cache->set($cache_key, $optimization_results, 86400);

        // Record usage with actual model information and raw AI text (Content Brief pattern)
        $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
        $ai_text = $optimization_results['_ai_text'] ?? null;
        $this->log_ai_usage($user_id, 'Homepage Hero Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);

        // Remove AI text from returned data to keep it clean
        unset($optimization_results['_ai_text']);

        return $optimization_results;
    }
}

```
