# thinkrank/trunk/includes/core/class-settings.php

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

- Page: https://pluginprobe.com/plugins/thinkrank/trunk/code/includes/core/class-settings.php
- Raw: https://pluginprobe.com/plugins/thinkrank/trunk/raw/includes/core/class-settings.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/trunk/code/includes/core/class-settings.php#L10-L20`.

```php
<?php

/**
 * Settings Class
 * 
 * Handles plugin settings and configuration using WordPress options
 * 
 * @package ThinkRank\Core
 * @since 1.0.0
 */

declare(strict_types=1);

namespace ThinkRank\Core;

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

/** 
 * Settings Class
 * 
 * Single Responsibility: Manage plugin settings and configuration
 * Uses WordPress options for storage.
 * 
 * Use Settings::instance() to get the shared instance rather than
 * creating new instances — this ensures the internal cache is shared
 * across all consumers and avoids redundant get_option() calls.
 * 
 * @since 1.0.0
 */
class Settings {

    /**
     * Canonical default AI model per provider.
     *
     * Single source of truth. Every call site that needs a provider's default
     * model — the $defaults array below, the AI client constructors, the manager
     * fallbacks, the content-brief generator and the usage-analytics endpoint —
     * MUST reference these constants instead of repeating the literal, so the
     * defaults can never silently drift out of sync (see issue #273).
     *
     * @since 1.28.0
     */
    const DEFAULT_OPENAI_MODEL     = 'gpt-5-nano';
    const DEFAULT_CLAUDE_MODEL     = 'claude-sonnet-5';
    const DEFAULT_GEMINI_MODEL     = 'gemini-3.5-flash';
    const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-4o-mini';

    /**
     * Canonical default author-archive templates.
     *
     * Same single-source-of-truth rule as the model constants above: the
     * defaults array, the REST endpoint, the get-settings ability and
     * Author_Archives_Manager all read these instead of repeating the literal,
     * which had already drifted — the title default hardcoded an en dash while
     * the feature resolves %separator% from Site Identity (#318).
     *
     * @since 1.29.1
     */
    /**
     * AI providers this plugin supports.
     *
     * Single source of truth for the REST enum, the connection test's dispatch
     * and sanitize_setting(), so the three cannot disagree about what is legal.
     *
     * @since 2.2.0
     * @var string[]
     */
    public const SUPPORTED_AI_PROVIDERS = ['openai', 'claude', 'gemini', 'openrouter'];

    /**
     * The stored value meaning "the user has not chosen a provider yet".
     *
     * A fresh install ships with no provider selected: picking one is the
     * user's call, and pre-selecting OpenAI made the settings screen open with
     * a warning about a missing key for a provider nobody had asked for (#572).
     * Every write path accepts this alongside SUPPORTED_AI_PROVIDERS.
     *
     * @since 2.1.3
     */
    public const AI_PROVIDER_NONE = '';

    /**
     * One-time marker for {@see Settings::retire_seeded_ai_provider()}.
     */
    private const PROVIDER_MIGRATION_OPTION  = 'thinkrank_ai_provider_migration';
    private const PROVIDER_MIGRATION_VERSION = '1';

    /**
     * Legal values for the `ai_provider` setting, including "not chosen".
     *
     * @since 2.1.3
     * @return string[]
     */
    public static function selectable_ai_providers(): array {
        return array_merge([self::AI_PROVIDER_NONE], self::SUPPORTED_AI_PROVIDERS);
    }

    const DEFAULT_AUTHOR_ARCHIVES_TITLE     = '%author_name% %separator% %site_title% %page%';
    const DEFAULT_AUTHOR_ARCHIVES_META_DESC = 'Articles written by %author_name% on %site_title%';

    /**
     * String settings where a stored empty string is a real value, not "unset".
     *
     * get() normally treats '' the same as a missing option and returns the
     * default, which is right for most keys — a blank API key or model name is
     * never what the user meant. For these template fields it is the opposite:
     * clearing the box means "render no template", and the consumers already
     * branch on an empty value. Without the opt-out the save appeared to
     * succeed and the default reappeared on the next request (#316).
     *
     * @since 1.29.1
     * @var string[]
     */
    private const EMPTY_IS_A_VALUE = [
        'author_archives_title',
        'author_archives_meta_desc',
        // '' is the "no provider chosen" state, not "fall back to the default".
        // Without this, deselecting a provider would be undone by any caller
        // that passes its own fallback to get() (#572).
        'ai_provider',
    ];

    /**
     * Shared singleton instance
     *
     * @var Settings|null
     */
    private static ?Settings $instance = null;

    /**
     * Get the shared Settings instance
     * 
     * Returns the instance registered in the main plugin's component
     * container, or creates a standalone instance if the plugin hasn't
     * loaded yet (e.g., during activation).
     * 
     * @since 1.10.0
     * @return Settings
     */
    public static function instance(): Settings {
        if (self::$instance !== null) {
            return self::$instance;
        }

        // Try to get from the main plugin container
        if (function_exists('thinkrank')) {
            $plugin = thinkrank();
            $component = $plugin->get_component('settings');
            if ($component instanceof self) {
                self::$instance = $component;
                return self::$instance;
            }
        }

        // Fallback: create standalone instance (during activation, etc.)
        self::$instance = new self();
        return self::$instance;
    }

    /**
     * Settings cache
     * 
     * @var array
     */
    private array $cache = [];

    /**
     * Default settings
     * 
     * @var array
     */
    private array $defaults = [
        // AI Settings
        'ai_provider' => self::AI_PROVIDER_NONE,
        'openai_api_key' => '',
        'openai_model' => self::DEFAULT_OPENAI_MODEL,
        'claude_api_key' => '',
        'claude_model' => self::DEFAULT_CLAUDE_MODEL, // Recommended default (best speed/quality balance)
        'gemini_api_key' => '',
        'gemini_model' => self::DEFAULT_GEMINI_MODEL,
        'openrouter_api_key' => '',
        'openrouter_model' => self::DEFAULT_OPENROUTER_MODEL,
        'max_tokens' => 1000,
        'temperature' => 0.7,

        // Google API Keys (encrypted)
        'google_analytics_api_key' => '',
        'google_search_console_api_key' => '',
        'google_pagespeed_api_key' => '',

        // Google OAuth Tokens (encrypted)
        'google_access_token' => '',
        'google_refresh_token' => '',
        'google_token_expires_in' => 0,
        'google_token_created' => 0,
        'google_account_connected' => false,

        // Google Analytics Pro Settings. Read/written by thinkrank-pro's
        // GoogleAnalyticsSettings.js through the settings-management endpoint —
        // no consumer exists in THIS repo, so don't dead-key these.
        'ga_analytics_account_id' => '',
        'ga_analytics_data_stream_id' => '',

        // Performance Settings
        'cache_duration' => 3600,
        'max_requests_per_minute' => 10,
        'enable_logging' => true,

        // Integration Settings
        'api_timeout' => 30,
        'enable_rate_limiting' => true,
        'auto_test_connections' => true,
        'retry_failed_requests' => true,

        // Social Platform Settings
        // Public IDs (not encrypted)
        'facebook_app_id' => '',
        'facebook_admins' => '',
        'youtube_channel_id' => '',
        'whatsapp_business_id' => '',
        // Verification codes (encrypted)
        'pinterest_site_verification' => '',
        'instagram_verification' => '',
        'tiktok_verification' => '',

        // SEO Settings
        'auto_optimize' => false,
        'seo_score_threshold' => 70,
        'enable_meta_generation' => true,
        'enable_schema_markup' => true,

        // Author Archives Settings
        'author_archives_enabled' => true,
        'author_archives_index' => true,
        'author_archives_show_empty' => false,
        'author_archives_title' => self::DEFAULT_AUTHOR_ARCHIVES_TITLE,
        'author_archives_meta_desc' => self::DEFAULT_AUTHOR_ARCHIVES_META_DESC,

        // UI Settings
        'show_welcome_message' => true,
        'dashboard_widgets' => ['seo_score', 'ai_usage', 'recent_briefs'],
        'editor_panel_position' => 'side',

        // Advanced Settings
        'debug_mode' => false,
        'retry_attempts' => 3,
        // Exposes the standalone Migration admin page for re-running SEO data
        // imports after setup. Hidden by default; opt-in for advanced/support use.
        'enable_migration_tools' => false,
        // Exposes ThinkRank's own export / restore. Off by default, like the
        // migration toggle above: both are occasional, admin-only tools, and a
        // menu item nobody asked for is a menu item in the way. Your data is
        // never locked in — the switch is one click away in
        // Settings > Import / Export, and turning it on immediately restores
        // the screen and the menu item. Off hides the export card and, unless
        // migration tools are on, the Import / Export menu item with it.
        'enable_import_export' => false,

        // Privacy Settings
        'data_retention_days' => 90,
        'anonymize_logs' => true,
        'share_usage_data' => false,

        // Integration Settings
        'google_analytics_id' => '',
        'search_console_property' => '',

        // SEO Analytics Settings
        'seo_analytics_enabled' => false,
        'seo_analytics_setup_completed' => false,
        'seo_analytics_google_analytics_property_id' => '',
        'seo_analytics_enable_ai_insights' => true,
        'seo_analytics_enable_automated_alerts' => false,
        'seo_analytics_enable_predictive_analysis' => false,
        'seo_analytics_monitoring_frequency' => 3600,
        'seo_analytics_alert_thresholds' => [],
        'seo_analytics_report_schedule' => 'weekly',
        'seo_analytics_data_retention_days' => 90,
        'seo_analytics_cache_analytics_data' => true,

        // Uninstall Settings
        'keep_data_on_uninstall' => true,

        // MCP (Model Context Protocol) Settings
        'enable_mcp' => false,
    ];

    /**
     * Encrypted settings keys
     *
     * @var array
     */
    private array $encrypted_keys = [
        // AI API Keys
        'openai_api_key',
        'claude_api_key',
        'gemini_api_key',
        'openrouter_api_key',
        // Google API Keys
        'google_analytics_api_key',
        'google_search_console_api_key',
        'google_pagespeed_api_key',
        'google_access_token',
        'google_refresh_token',
        // Social Platform Verification Codes (sensitive)
        'pinterest_site_verification',
        'instagram_verification',
        'tiktok_verification',
    ];

    /**
     * Initialize settings
     * 
     * @return void
     */
    public function init(): void {
        add_action('admin_init', [$this, 'register_settings']);
        // Admin-only: a front-end pageview can never need this migration, and
        // the marker is a non-autoloaded option, so hooking it unconditionally
        // bought one dedicated query on every request for the life of the
        // install (#588).
        if (is_admin()) {
            add_action('init', [self::class, 'retire_seeded_ai_provider']);
        }
    }

    /**
     * Clear the OpenAI selection that older versions seeded on activation.
     *
     * Changing the default only helps installs created after the change. Every
     * site activated before it still carries `ai_provider = 'openai'` written by
     * Activator::set_default_options(), and still opens Settings warning about a
     * missing key for a provider nobody picked — the whole complaint in #572.
     *
     * "OpenAI with no OpenAI key" is provably not a user's choice: the settings
     * form refuses to save a provider without a key, so the only way to reach
     * that state is the old activation seed. A site that genuinely chose OpenAI
     * has a key and is left alone, as is any site on another provider.
     *
     * Version-gated so it runs once and never fights a user who later clears
     * their key but keeps the provider selected.
     *
     * @since 2.1.3
     *
     * @return void
     */
    public static function retire_seeded_ai_provider(): void {
        if (get_option(self::PROVIDER_MIGRATION_OPTION) === self::PROVIDER_MIGRATION_VERSION) {
            self::promote_migration_marker_to_autoload();
            return;
        }

        // Record first: a site that somehow fails the checks below must not
        // re-test on every request for the rest of its life. Autoloaded on
        // purpose — it is a short write-once flag that is read on every admin
        // request, which is exactly what autoload is for; storing it
        // non-autoloaded bought a dedicated query per request instead (#588).
        update_option(self::PROVIDER_MIGRATION_OPTION, self::PROVIDER_MIGRATION_VERSION, true);

        if (get_option('thinkrank_ai_provider', null) !== 'openai') {
            return;
        }

        // Read the stored option directly rather than through Settings::get().
        // Only emptiness matters here, and get() adds two things this decision
        // must not depend on: a per-instance cache that may already be primed,
        // and decryption that can yield '' for a key that is genuinely present.
        // The raw option is non-empty whenever a key exists, encrypted or not.
        if ('' !== (string) get_option('thinkrank_openai_api_key', '')) {
            // A real choice, backed by a key. Leave it.
            return;
        }

        // Write through the shared instance, not a throwaway one: set() refreshes
        // only the cache of the object it is called on, so a private instance
        // would leave the registered component serving the old value for the
        // rest of this request — including to the admin page it localizes.
        self::instance()->set('ai_provider', self::AI_PROVIDER_NONE);
    }

    /**
     * Move a legacy migration marker into the autoloaded set.
     *
     * Sites that ran the migration on 2.1.3 wrote the marker with
     * `autoload = false`, and the version gate above returns before the write
     * that would correct it — so those installs keep paying a dedicated query
     * to read a one-byte flag on every admin request, which is the cost #588
     * was about. Promote it once.
     *
     * Free to test: alloptions is loaded from the object cache once per request
     * regardless, and an autoloaded marker is in it, so the steady state after
     * the promotion is a cache lookup and nothing else. wp_set_option_autoload()
     * arrived in WP 6.4 and the plugin supports 6.0, hence the guard.
     *
     * @since 2.2.0
     *
     * @return void
     */
    private static function promote_migration_marker_to_autoload(): void {
        if (!function_exists('wp_set_option_autoload') || !function_exists('wp_load_alloptions')) {
            return;
        }

        if (array_key_exists(self::PROVIDER_MIGRATION_OPTION, wp_load_alloptions())) {
            return;
        }

        wp_set_option_autoload(self::PROVIDER_MIGRATION_OPTION, true);
    }

    /**
     * Register WordPress settings
     * 
     * @return void
     */
    public function register_settings(): void {
        register_setting('thinkrank_settings', 'thinkrank_settings', [
            'sanitize_callback' => [$this, 'sanitize_settings'],
            'default' => $this->defaults,
        ]);
    }

    /**
     * Warm the option cache for a batch of setting keys in one query.
     *
     * Every `thinkrank_*` option is autoload=off, so WordPress cannot serve
     * them from `alloptions` and each get() below is its own round-trip. A
     * caller that reads a known list of keys should prime it first: on a
     * ~1ms managed-hosting round-trip, sixteen of those on an anonymous
     * pageview is ~16ms spent on values the page may not use (#393).
     *
     * get() memoizes within the request, so only the first read of each key
     * ever reaches the database — this is what collapses those first reads.
     * Keys already memoized are left out of the batch.
     *
     * wp_prime_option_caches() is WP 6.4+; the plugin supports 6.0, so an
     * older site simply keeps the previous behaviour.
     *
     * @since 2.1.0
     *
     * @param string[] $keys Setting keys, without the `thinkrank_` prefix.
     * @return void
     */
    public function prime(array $keys): void {
        if (!function_exists('wp_prime_option_caches')) {
            return;
        }

        $unread = [];

        foreach ($keys as $key) {
            if (!isset($this->cache[$key])) {
                $unread[] = 'thinkrank_' . $key;
            }
        }

        if (!empty($unread)) {
            wp_prime_option_caches($unread);
        }
    }

    /**
     * Get setting value
     * 
     * @param string $key Setting key
     * @param mixed $fallback Default value
     * @param int $user_id User ID (0 for global, >0 for user-specific)
     * @return mixed Setting value
     */
    public function get(string $key, $fallback = null, int $user_id = 0) {
        // Check cache first
        $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;

        if (isset($this->cache[$cache_key])) {
            return $this->maybe_decrypt($key, $this->cache[$cache_key]);
        }

        // Load from WordPress options or user meta
        if ($user_id > 0) {
            $value = get_user_meta($user_id, "thinkrank_{$key}", true);
        } else {
            $value = get_option("thinkrank_{$key}", null);
        }



        // Handle boolean settings with special logic for WordPress storage quirks
        if (isset($this->defaults[$key]) && is_bool($this->defaults[$key])) {
            // Convert string representations back to booleans
            if ('1' === $value || 1 === $value || true === $value || 'true' === $value) {
                $value = true;
            } elseif ('0' === $value || 0 === $value || false === $value || 'false' === $value) {
                $value = false;
            } elseif (null === $value || '' === $value) {
                // For boolean settings, empty string could mean false was stored
                // null means option doesn't exist, empty string means it was stored as empty
                if (null !== $value && $value === '') {
                    // Empty string exists in DB, this likely means false was stored
                    $value = false;
                } else {
                    // Option doesn't exist, use default
                    $value = $fallback ?? $this->defaults[$key];
                }
            }
        } else {
            // Non-boolean settings: use default if not found.
            //
            // get_option() above is called with a null default, so null means
            // "no row" while '' means a value was deliberately stored. For most
            // keys we collapse the two — an empty string is treated as unset so
            // the documented default applies. Keys in EMPTY_IS_A_VALUE opt out:
            // there, clearing the field is a real choice the consumer honours,
            // and folding it back into the default made the save look like it
            // silently failed (#316).
            $empty_is_unset = !in_array($key, self::EMPTY_IS_A_VALUE, true);

            if (null === $value || ($empty_is_unset && '' === $value)) {
                $value = $fallback ?? ($this->defaults[$key] ?? null);
            }
        }

        // Cache the value
        $this->cache[$cache_key] = $value;

        return $this->maybe_decrypt($key, $value);
    }

    /**
     * Set setting value
     * 
     * @param string $key Setting key
     * @param mixed $value Setting value
     * @param int $user_id User ID (0 for global, >0 for user-specific)
     * @return bool Success status
     */
    public function set(string $key, $value, int $user_id = 0): bool {
        // Check if key exists in defaults
        if (!array_key_exists($key, $this->defaults)) {
            return false;
        }

        // Apply the declared per-key sanitizer before anything is stored. This
        // is the path essentially every caller takes, so skipping it left the
        // whole sanitize_setting() switch unreachable — max_tokens,
        // cache_duration and temperature persisted whatever string arrived.
        $value = $this->sanitize_setting($key, $value);

        // Encrypt if needed
        $encrypted_value = $this->maybe_encrypt($key, $value);

        // Save to WordPress options or user meta
        if ($user_id > 0) {
            $result = update_user_meta($user_id, "thinkrank_{$key}", $encrypted_value);
        } else {
            $option_name = "thinkrank_{$key}";
            $is_sensitive = in_array($key, $this->encrypted_keys, true);

            // Determine if option exists
            $existing = get_option($option_name, '__tr_not_set__');
            if ($existing === '__tr_not_set__') {
                // First-time save: set autoload=no for sensitive options
                $autoload = $is_sensitive ? 'no' : 'yes';
                $result = add_option($option_name, $encrypted_value, '', $autoload);
            } else {
                // Update existing option; enforce autoload=no for sensitive options
                if ($is_sensitive) {
                    $result = update_option($option_name, $encrypted_value, 'no');
                } else {
                    $result = update_option($option_name, $encrypted_value);
                }
            }

            // Verify value actually saved (update_option returns false when unchanged)
            $saved_value = get_option($option_name, 'NOT_FOUND');
            // The loose branch is load-bearing: get_option() returns the stored
            // STRING ('1', '0', '30') while $encrypted_value may be the original
            // bool/int. On an unchanged re-save update_option() returns false, so
            // this comparison is the only thing that marks the save successful —
            // strict-only here made every unchanged-boolean re-save report failure.
            $values_match = ($saved_value === $encrypted_value) ||
                ($saved_value == $encrypted_value && $encrypted_value !== 'NOT_FOUND'); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- intentional type-tolerant verify, see above.

            // Consider it successful if the value was saved correctly
            $result = $result || $values_match;
        }

        // Update cache
        if ($result) {
            $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
            $this->cache[$cache_key] = $encrypted_value;

            // Invalidate bulk cache when individual setting changes
            $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
            wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
        }

        return $result !== false;
    }

    /**
     * Delete setting
     * 
     * @param string $key Setting key
     * @param int $user_id User ID (0 for global, >0 for user-specific)
     * @return bool Success status
     */
    public function delete(string $key, int $user_id = 0): bool {
        // Remove from WordPress options or user meta
        if ($user_id > 0) {
            $result = delete_user_meta($user_id, "thinkrank_{$key}");
        } else {
            $result = delete_option("thinkrank_{$key}");
        }

        // Remove from cache
        if ($result) {
            $cache_key = $user_id > 0 ? "user_{$user_id}_{$key}" : $key;
            unset($this->cache[$cache_key]);

            // Also invalidate the bulk cache written by get_all(), or it serves
            // stale values for up to 5 minutes after a delete.
            $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
            wp_cache_delete($bulk_cache_key, 'thinkrank_settings');
        }

        return $result;
    }

    /**
     * Setting keys whose values are encrypted at rest.
     *
     * Exposed so callers that must never emit a credential — the data exporter
     * in particular — can filter against the same list this class encrypts
     * with, instead of keeping a copy that silently drifts when a key is added.
     *
     * @since 2.2.0
     *
     * @return string[] Setting keys.
     */
    public function get_encrypted_keys(): array {
        return $this->encrypted_keys;
    }

    /**
     * Get all settings (optimized with bulk caching)
     *
     * @param int $user_id User ID (0 for global)
     * @return array All settings
     */
    public function get_all(int $user_id = 0): array {
        // Check bulk cache first
        $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';

        $cached_settings = wp_cache_get($bulk_cache_key, 'thinkrank_settings');
        if ($cached_settings !== false) {
            return $cached_settings;
        }

        $settings = [];

        foreach (array_keys($this->defaults) as $key) {
            $settings[$key] = $this->get($key, null, $user_id);
        }

        // Cache all settings for 5 minutes
        wp_cache_set($bulk_cache_key, $settings, 'thinkrank_settings', 300);

        return $settings;
    }

    /**
     * Reset settings to defaults
     * 
     * @param int $user_id User ID (0 for global)
     * @return bool Success status
     */
    public function reset(int $user_id = 0): bool {
        $success = true;

        foreach (array_keys($this->defaults) as $key) {
            if (!$this->delete($key, $user_id)) {
                $success = false;
            }
        }

        // Clear cache — both the instance array and the bulk object cache
        // written by get_all(), which would otherwise return stale pre-reset
        // values for up to 5 minutes.
        $this->cache = [];
        $bulk_cache_key = $user_id > 0 ? "bulk_user_{$user_id}" : 'bulk_global';
        wp_cache_delete($bulk_cache_key, 'thinkrank_settings');

        return $success;
    }

    /**
     * Sanitize settings
     * 
     * @param array $settings Settings array
     * @return array Sanitized settings
     */
    public function sanitize_settings(array $settings): array {
        $sanitized = [];

        foreach ($settings as $key => $value) {
            $sanitized[$key] = $this->sanitize_setting($key, $value);
        }

        return $sanitized;
    }

    /**
     * Sanitize a nested array, preserving its shape.
     *
     * Scalars keep their type (an int threshold stays an int); strings are
     * text-sanitized; objects are dropped, since no setting stores one.
     *
     * @since 2.2.0
     * @param array $value Array to sanitize.
     * @param int   $depth Current recursion depth.
     * @return array
     */
    private function sanitize_array_recursive(array $value, int $depth = 0): array {
        // Settings are configuration, not arbitrary payloads; a cap keeps a
        // malformed deep structure from recursing without bound.
        if ($depth > 10) {
            return [];
        }

        $sanitized = [];
        foreach ($value as $item_key => $item) {
            $key = is_string($item_key) ? sanitize_key($item_key) : $item_key;

            if (is_array($item)) {
                $sanitized[$key] = $this->sanitize_array_recursive($item, $depth + 1);
            } elseif (is_object($item)) {
                continue;
            } elseif (is_bool($item) || is_int($item) || is_float($item)) {
                $sanitized[$key] = $item;
            } else {
                $sanitized[$key] = sanitize_text_field((string) $item);
            }
        }

        return $sanitized;
    }

    /**
     * Sanitize individual setting
     * 
     * @param string $key Setting key
     * @param mixed $value Setting value
     * @return mixed Sanitized value
     */
    private function sanitize_setting(string $key, $value) {
        // Only keys that are declared array-typed may receive an array. For a
        // scalar key an array/object value is malformed input, and the scalar
        // sanitizers below would fatal on it, so fall back to the declared
        // default instead of letting the bad value through.
        if (is_array($value) || is_object($value)) {
            if (!is_array($this->defaults[$key] ?? null)) {
                return $this->defaults[$key] ?? '';
            }
            $value = (array) $value;
        }

        switch ($key) {
            case 'openai_api_key':
            case 'claude_api_key':
            case 'gemini_api_key':
            case 'openrouter_api_key':
                return sanitize_text_field($value);

            case 'ai_provider':
                // sanitize_key() maps '' to '', which is AI_PROVIDER_NONE — the
                // deliberate "no provider chosen" state, so it must survive here
                // rather than being folded back into the default (#572).
                $provider = sanitize_key($value);

                return in_array($provider, self::selectable_ai_providers(), true)
                    ? $provider
                    : $this->defaults['ai_provider'];

            case 'openai_model':
            case 'claude_model':
            case 'gemini_model':
            case 'openrouter_model':
                // Model ids may contain dots and slashes (e.g. "gpt-4.1" or
                // "openai/gpt-4o-mini") and users can enter custom models, so
                // sanitize_key() would corrupt them — use text-field sanitizing.
                return sanitize_text_field($value);

            case 'max_tokens':
            case 'cache_duration':
            case 'max_requests_per_minute':
            case 'seo_score_threshold':
            case 'api_timeout':
            case 'retry_attempts':
            case 'data_retention_days':
            case 'monitoring_frequency':
                return absint($value);

            case 'temperature':
                return (float) $value;

            case 'dashboard_widgets':
                return is_array($value) ? array_map('sanitize_key', $value) : [];

            case 'seo_analytics_alert_thresholds':
                // A threshold_name => value map where the values are numbers
                // (e.g. traffic_drop_percentage => 20) as well as strings, so
                // sanitize each value by its own type rather than forcing every
                // one through sanitize_key() — that turned ints into strings.
                if (!is_array($value)) {
                    return [];
                }
                $thresholds = [];
                foreach ($value as $threshold_key => $threshold_value) {
                    if (is_array($threshold_value)) {
                        continue;
                    }
                    if (is_bool($threshold_value)) {
                        // Keep booleans as booleans. is_numeric() is false for
                        // one, so it used to fall to the string arm and a true
                        // came back as "1" — invisible while this ran only on
                        // the register_setting() path, now that set() routes
                        // every write through here it is a type change on save.
                        $thresholds[sanitize_key($threshold_key)] = $threshold_value;
                        continue;
                    }
                    $thresholds[sanitize_key($threshold_key)] = is_numeric($threshold_value)
                        ? $threshold_value + 0
                        : sanitize_text_field((string) $threshold_value);
                }
                return $thresholds;

            case 'google_analytics_property_id':
            case 'seo_analytics_google_analytics_property_id':
            case 'seo_analytics_report_schedule':
                return sanitize_text_field($value);

            case 'robots_txt_content':
                // Multi-line content — sanitize_text_field() collapses newlines
                // and would flatten the whole file onto a single line. set()
                // routes every write through here, so a caller that chose
                // sanitize_textarea_field() itself is otherwise silently
                // overridden by the default: arm below (#587).
                return sanitize_textarea_field($value);

            default:
                if (is_bool($value)) {
                    return (bool) $value;
                } elseif (is_string($value)) {
                    return sanitize_text_field($value);
                } elseif (is_array($value)) {
                    // Recurse rather than drop. Skipping nested members was
                    // harmless while this ran only on the register_setting()
                    // path, but set() now routes every write through here, and
                    // structured settings (lists of maps) were being silently
                    // emptied on save.
                    return $this->sanitize_array_recursive($value);
                }
                return $value;
        }
    }

    /**
     * Marker prefix for values encrypted with the libsodium scheme below.
     */
    private const ENC_PREFIX = 'trenc:v1:';

    /**
     * Encrypt secret settings (API keys, OAuth tokens) at rest using libsodium
     * authenticated encryption. Non-secret keys and environments without sodium
     * fall back to plaintext so behaviour stays stable.
     *
     * @param string $key Setting key
     * @param mixed $value Setting value
     * @return mixed Encrypted payload (string) or original value
     */
    private function maybe_encrypt(string $key, $value) {
        if (!in_array($key, $this->encrypted_keys, true) || !is_string($value) || '' === $value) {
            return $value;
        }

        // Same scheme, same key, same prefix — it just lives in Secret_At_Rest
        // now so the MCP pairing token can use it too (#396).
        return Secret_At_Rest::encrypt($value);
    }

    /**
     * Decrypt a value previously encrypted by maybe_encrypt. Values without the
     * marker prefix are legacy plaintext and returned untouched (backward compat).
     *
     * @param string $key Setting key
     * @param mixed $value Setting value
     * @return mixed Decrypted or original value
     */
    private function maybe_decrypt(string $key, $value) {
        if (!is_string($value) || strncmp($value, self::ENC_PREFIX, strlen(self::ENC_PREFIX)) !== 0) {
            return $value; // legacy plaintext or non-string
        }

        $plain = Secret_At_Rest::decrypt($value);

        if ('' === $plain) {
            // We reach here only when sodium is available and a key was
            // derivable, so this is a genuine failure: the auth salt changed
            // (config rotated, or the site was migrated without wp-config) or
            // the row is corrupt. The value is unrecoverable either way.
            //
            // Returning the ciphertext would send it upstream as a bearer
            // token or API key, producing an opaque 401 far from the cause.
            // Return empty so callers see "no credential" and can prompt for
            // a reconnect instead.
            return '';
        }

        return $plain;
    }
}

```
