<?php
/**
 * Costudio_Admin_Ajax_Settings — Settings, diagnostics and model discovery.
 *
 * Owns the Settings tab's heavy work:
 *   - Save settings (AI/image/search/license/language)
 *   - Diagnose ContentOS API connectivity (DNS, TCP, HTTPS, REST)
 *   - Fetch available text + image models from provider APIs
 *   - Test AI, image and web-search provider keys
 *
 * Also hosts the private "is this an article-generation text model?" filters
 * used when listing Gemini/OpenAI/Claude model catalogs.
 *
 * Extracted from Costudio_Admin as part of Task 2 (PR 4 — Settings AJAX split).
 *
 * @package Contentosapp_Studio
 * @since   3.2.0
 */

defined( 'ABSPATH' ) || exit;

class Costudio_Admin_Ajax_Settings extends Costudio_Admin_Ajax_Base {

	/** @var self|null */
	private static ?self $instance = null;

	private function __construct() {}

	public static function get_instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	public function register_hooks(): void {
		$this->register_ajax_actions(
			array(
				'costudio_diagnose_api',
				'costudio_save_settings',
				'costudio_fetch_models',
				'costudio_fetch_image_models',
				'costudio_fetch_search_models',
				'costudio_test_ai_connection',
				'costudio_test_image_connection',
				'costudio_test_search_connection',
				'costudio_test_telemetry',
				'costudio_sync_license_quota',
				'costudio_save_telemetry_consent',
			)
		);
	}

	/**
	 * AJAX: persist the user's telemetry consent decision.
	 *
	 * Called from:
	 *   - Setup wizard (Step 3) — initial decision
	 *   - Admin notice (existing installs) — migration decision
	 *   - Settings → Advanced toggle — change decision later
	 *
	 * Records both the enabled flag and the consent version, so the admin notice
	 * stops appearing once the user has chosen.
	 */
	public function ajax_costudio_save_telemetry_consent(): void {
		$in = $this->read_request( array( 'consent' => 'text' ) );

		if ( ! class_exists( 'Costudio_Telemetry' ) ) {
			wp_send_json_error( 'Costudio_Telemetry class not loaded.' );
		}

		$consent  = $in['consent'];
		$accepted = in_array( $consent, [ '1', 'true', 'yes', 'on' ], true );

		Costudio_Telemetry::record_consent( $accepted );

		wp_send_json_success(
			array(
				'enabled' => $accepted,
				'message' => $accepted
					? __( 'Thanks for helping Contentosapp Studio improve!', 'contentosapp-studio' )
					: __( 'Got it. Telemetry is disabled.', 'contentosapp-studio' ),
			)
		);
	}

	/**
	 * AJAX: Test the telemetry pipeline end-to-end.
	 *
	 * Queues a synthetic event, forces a flush, and reports back the
	 * before/after queue size + last HTTP status. This isolates whether the
	 * problem is on the client (queue empty / disabled), at auth (HMAC / license),
	 * or at the server (firewall / 5xx).
	 */
	public function ajax_costudio_test_telemetry(): void {
		$this->read_request( array() );

		if ( ! class_exists( 'Costudio_Telemetry' ) ) {
			wp_send_json_error( 'Costudio_Telemetry class not loaded.' );
		}

		$queue_before = count( (array) get_option( 'costudio_telemetry_queue', array() ) );
		$status_before = (string) get_option( 'costudio_telemetry_last_status', '' );

		Costudio_Telemetry::queue(
			'telemetry.test',
			array(
				'origin' => 'admin_test_button',
				'ts'     => time(),
			)
		);

		$queue_after_enqueue = count( (array) get_option( 'costudio_telemetry_queue', array() ) );

		Costudio_Telemetry::flush();

		$queue_after_flush = count( (array) get_option( 'costudio_telemetry_queue', array() ) );
		$status_after      = (string) get_option( 'costudio_telemetry_last_status', '' );
		$license_key       = (string) get_option( 'costudio_license_key', '' );
		$endpoint          = method_exists( 'Costudio_Telemetry', 'get_endpoint' )
			? Costudio_Telemetry::get_endpoint()
			: COSTUDIO_API_URL . '/wp-json/contentosapp-api/v1/telemetry/ingest';
		$enabled           = method_exists( 'Costudio_Telemetry', 'is_enabled' )
			? Costudio_Telemetry::is_enabled()
			: true;

		$flushed = $queue_after_flush < $queue_after_enqueue;

		wp_send_json_success(
			array(
				'flushed'             => $flushed,
				'enabled'             => $enabled,
				'endpoint'            => $endpoint,
				'license_present'     => $license_key !== '',
				'license_kind'        => $license_key === '' ? 'none' : ( strpos( $license_key, 'FOS2.' ) === 0 ? 'paid_FOS2' : ( strpos( $license_key, 'starter:' ) === 0 ? 'starter' : 'commercial_or_legacy' ) ),
				'queue_before'        => $queue_before,
				'queue_after_enqueue' => $queue_after_enqueue,
				'queue_after_flush'   => $queue_after_flush,
				'last_status_before'  => $status_before,
				'last_status_after'   => $status_after,
			)
		);
	}

	/**
	 * Throttle diagnostic endpoints: one request per N seconds per user.
	 */
	private function throttle_diagnostics(): void {
		$user_id = get_current_user_id();
		$key     = 'costudio_diag_throttle_' . $user_id;

		if ( false !== get_transient( $key ) ) {
			wp_send_json_error(
				sprintf(
					/* translators: %d: seconds to wait */
					__( 'Please wait %d seconds before testing again.', 'contentosapp-studio' ),
					Costudio_Admin::DIAG_THROTTLE_SECONDS
				)
			);
		}

		set_transient( $key, 1, Costudio_Admin::DIAG_THROTTLE_SECONDS );
	}

	/**
	 * AJAX: Diagnose connectivity to ContentOS API.
	 * Tests PHP environment, DNS, TCP socket, HTTPS, REST API, and external reachability.
	 */
	public function ajax_costudio_diagnose_api(): void {
		$this->read_request( array() );
		$this->throttle_diagnostics();

		$results = array();
		$host    = 'api.contentosapp.com';
		$base    = 'https://' . $host;

		// ── 0. PHP / WordPress HTTP environment ───────────────────────────────
		$results[] = array(
			'test'   => 'PHP max_execution_time',
			'ok'     => true,
			'detail' => ini_get( 'max_execution_time' ) . 's',
		);

		// ── 1. DNS resolution ──────────────────────────────────────────────────
		$dns_start = microtime( true );
		$ip        = gethostbyname( $host );
		$dns_ms    = round( ( microtime( true ) - $dns_start ) * 1000 );
		$dns_ok    = ( $ip !== $host );
		$results[] = array(
			'test'   => 'DNS resolve ' . $host,
			'ok'     => $dns_ok,
			'detail' => $dns_ok ? $ip . ' (' . $dns_ms . ' ms)' : 'FAILED — server cannot resolve this domain',
		);

		if ( ! $dns_ok ) {
			wp_send_json_success( $results );
			return;
		}

		// ── 2. WordPress HTTP to contentosapp.com ──────────────────────────────
		$run_http = function ( string $label, string $url, string $method = 'GET', array $extra = array() ) use ( &$results ) {
			$args = array_merge(
				array(
					'timeout'   => 15,
					'sslverify' => costudio_sslverify( 'diagnose' ),
				),
				$extra
			);

			$start = microtime( true );
			$resp  = ( $method === 'POST' )
				? wp_remote_post( $url, $args )
				: wp_remote_get( $url, $args );
			$ms = round( ( microtime( true ) - $start ) * 1000 );

			if ( is_wp_error( $resp ) ) {
				$results[] = array(
					'test'   => $label,
					'ok'     => false,
					'detail' => $resp->get_error_message() . ' (' . $ms . ' ms)',
				);
				return false;
			}
			$code = (int) wp_remote_retrieve_response_code( $resp );
			// 4xx (e.g. 403) = reachable but not "OK" for this diagnostic.
			$results[] = array(
				'test'   => $label,
				'ok'     => ( $code >= 200 && $code < 400 ),
				'detail' => 'HTTP ' . $code . ' (' . $ms . ' ms)',
			);
			return true;
		};

		$run_http( 'HTTPS GET ' . $host . ' (homepage)', $base . '/' );
		$run_http( 'REST GET /wp-json/', $base . '/wp-json/', 'GET' );
		$run_http( 'REST GET /ping', $base . '/wp-json/contentosapp-api/v1/ping', 'GET' );
		$run_http(
			'POST ContentOS API ping',
			costudio_api_ajax_url( 'cap_api_ping' ),
			'POST',
			array( 'body' => 'ping=1' )
		);

		// ── 5. Server identity ─────────────────────────────────────────────────
		$results[] = array(
			'test'   => 'Server outgoing IP',
			'ok'     => true,
			'detail' => isset( $_SERVER['SERVER_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_ADDR'] ) ) : 'Unknown',
		);

		wp_send_json_success( $results );
	}

	/**
	 * AJAX: Force a full sync of license plan and usage quota.
	 */
	public function ajax_costudio_sync_license_quota(): void {
		$this->read_request( array() );

		$data = Costudio_Standalone_License::sync_remote_usage( true );

		if ( ! $data ) {
			// Retrieve the last sync error if possible, or use a generic one.
			$error = get_transient( 'costudio_last_sync_error' ) ?: __( 'Failed to sync with the license server.', 'contentosapp-studio' );
			wp_send_json_error( $error );
		}

		$addon_balance     = Costudio_Standalone_License::get_addon_balance();
		$reset_date        = gmdate( 'Y-m-01', strtotime( 'first day of next month' ) );
		$reset_date_format = date_i18n( get_option( 'date_format' ), strtotime( $reset_date ) );

		wp_send_json_success( array(
			'plan'               => strtoupper( $data['plan'] ),
			'usage_count'        => $data['usage_count'],
			'limit'              => $data['limit'],
			'image_limit'        => (int) ( $data['image_limit'] ?? ( $data['capabilities']['image_count'] ?? 0 ) ),
			'capabilities'       => array(
				'image_count' => (int) ( $data['capabilities']['image_count'] ?? ( $data['image_limit'] ?? 0 ) ),
			),
			'status'             => $data['status'],
			'addon_balance'      => (int) ( $addon_balance['balance'] ?? 0 ),
			'addon_enabled'      => ! empty( $addon_balance['enabled'] ),
			/* translators: %s: human-formatted date when the quota resets */
			'reset_date_format'  => sprintf( __( 'Resets %s', 'contentosapp-studio' ), $reset_date_format ),
			'message'            => __( 'Quota and license synced successfully.', 'contentosapp-studio' ),
		) );
	}

	/**
	 * AJAX: Save general settings.
	 */
	public function ajax_costudio_save_settings(): void {
		$in = $this->read_request(
			array(
				'ai_mode'                     => 'text',
				'ai_provider'                 => 'text',
				'ai_model'                    => 'text',
				'ai_api_key'                  => 'text',
				'ai_image_provider'           => 'text',
				'ai_image_model'              => 'text',
				'ai_image_api_key'            => 'text',
				'costudio_image_mode'         => 'text',
				'costudio_image_quantity'     => 'int',
				'search_api_provider'         => 'text',
				'search_grounding'            => 'bool',
				'search_api_model'            => 'text',
				'search_max_results'          => 'int',
				'search_api_key'              => 'text',
				'default_language'            => 'text',
				'default_content_language'    => 'text',
				'costudio_deactivate_license' => 'bool',
				'costudio_pro_license_key'    => 'text',
				'costudio_license_email'      => 'email',
			)
		);

		$settings = costudio_settings();
		$previous_content_language = $settings ? (string) $settings->get( 'default_content_language', 'en_US' ) : 'en_US';
		if ( ! $settings ) {
			wp_send_json_error( __( 'Settings system unavailable.', 'contentosapp-studio' ) );
		}

		$data = array();
		$submitted_ai_mode = $this->was_present( 'ai_mode' )
			? $in['ai_mode']
			: (string) $settings->get( 'ai_mode', 'contentos' );
		if ( ! in_array( $submitted_ai_mode, array( 'contentos', 'byok' ), true ) ) {
			$submitted_ai_mode = 'contentos';
		}
		$is_starter_auto = (
			'contentos' === $submitted_ai_mode
			&& class_exists( 'Costudio_Standalone_License' )
			&& Costudio_Standalone_License::get_instance()->get_managed_image_limit_per_article() <= 0
		);

		// AI Provider (text)
		if ( $this->was_present( 'ai_provider' ) ) {
			$data['ai_provider'] = $in['ai_provider'];
		}
		if ( $this->was_present( 'ai_model' ) ) {
			$data['ai_model'] = $in['ai_model'];
		}
		$new_api_key = $this->was_present( 'ai_api_key' ) ? $in['ai_api_key'] : '';
		if ( $new_api_key !== '' && strpos( $new_api_key, '•' ) === false ) {
			// New key submitted — check if it differs from the stored one.
			$old_key = $settings->get( 'ai_api_key', '' );
			if ( $new_api_key !== $old_key ) {
				// Different key: invalidate model cache for this provider so the
				// user is prompted to re-fetch models for the new account.
				$provider_for_cache = $this->was_present( 'ai_provider' )
					? $in['ai_provider']
					: (string) $settings->get( 'ai_provider', 'gemini' );
				$cache              = get_option( 'costudio_models_cache', array() );
				unset( $cache[ $provider_for_cache ] );
				update_option( 'costudio_models_cache', $cache, false );
			}
			$data['ai_api_key'] = $new_api_key;
		}

		if ( $this->was_present( 'ai_image_provider' ) ) {
			$data['ai_image_provider'] = $in['ai_image_provider'];
		}
		if ( $this->was_present( 'ai_image_model' ) ) {
			$data['ai_image_model'] = $in['ai_image_model'];
		}
		$new_image_key = $this->was_present( 'ai_image_api_key' ) ? $in['ai_image_api_key'] : '';
		if ( $new_image_key !== '' && strpos( $new_image_key, '•' ) === false ) {
			$old_image_key = $settings->get( 'ai_image_api_key', '' );
			if ( $new_image_key !== $old_image_key ) {
				$img_provider_for_cache = $this->was_present( 'ai_image_provider' )
					? $in['ai_image_provider']
					: (string) $settings->get( 'ai_image_provider', '' );
				$img_cache              = get_option( 'costudio_image_models_cache', array() );
				unset( $img_cache[ $img_provider_for_cache ] );
				update_option( 'costudio_image_models_cache', $img_cache, false );
			}
			$data['ai_image_api_key'] = $new_image_key;
		}

		if ( $this->was_present( 'costudio_image_mode' ) ) {
			$mode = $in['costudio_image_mode'];
			if ( in_array( $mode, array( 'generate', 'placeholder', 'none' ), true ) ) {
				if ( $is_starter_auto && 'generate' === $mode ) {
					$mode = 'placeholder';
				}
				$data['costudio_image_mode'] = $mode;
			}
		}
		if ( $this->was_present( 'costudio_image_quantity' ) ) {
			$qty                        = $in['costudio_image_quantity'];
			$data['costudio_image_quantity'] = class_exists( 'Costudio_Standalone_Settings' )
				? Costudio_Standalone_Settings::clamp_image_quantity( $qty, $submitted_ai_mode )
				: max( 1, min( 3, $qty ) );
		}

		if ( $this->was_present( 'search_api_provider' ) ) {
			$candidate = $in['search_api_provider'];
			$allowed   = class_exists( 'Costudio_Search_Engine' )
				? Costudio_Search_Engine::allowed_providers()
				: array( 'tavily', 'serper', 'exa' );
			$data['search_api_provider'] = ( $candidate === '' || in_array( $candidate, $allowed, true ) )
				? $candidate
				: '';
		}
		if ( $this->was_present( 'search_grounding' ) ) {
			$data['search_grounding'] = $in['search_grounding'];
		}
		$effective_search_provider = isset( $data['search_api_provider'] )
			? (string) $data['search_api_provider']
			: (string) $settings->get( 'search_api_provider', '' );
		if ( $this->was_present( 'search_api_model' ) ) {
			$data['search_api_model'] = self::normalize_search_model_for_provider( $effective_search_provider, $in['search_api_model'] );
		} elseif ( $this->was_present( 'search_api_provider' ) ) {
			$data['search_api_model'] = self::default_search_model_for_provider( $effective_search_provider );
		}
		if ( $this->was_present( 'search_max_results' ) ) {
			$data['search_max_results'] = class_exists( 'Costudio_Standalone_Settings' )
				? Costudio_Standalone_Settings::clamp_search_results( $in['search_max_results'] )
				: max( 1, min( 20, (int) $in['search_max_results'] ) );
		}
		$new_search_key = $this->was_present( 'search_api_key' ) ? $in['search_api_key'] : '';
		if ( $new_search_key !== '' && strpos( $new_search_key, '•' ) === false ) {
			$data['search_api_key'] = $new_search_key;
		}

		// AI mode: 'contentos' (use ContentOS Auto service quota) | 'byok' (own API key)
		if ( $this->was_present( 'ai_mode' ) ) {
			$data['ai_mode'] = $submitted_ai_mode;
		}

		// Production mode is the source of truth for cost routing. Hidden UI
		// fields may still be posted by the browser, so normalize here too.
		if ( 'contentos' === $submitted_ai_mode ) {
			$image_mode = $data['costudio_image_mode'] ?? (string) $settings->get( 'costudio_image_mode', 'generate' );
			if ( $is_starter_auto && 'generate' === $image_mode ) {
				$image_mode                      = 'placeholder';
				$data['costudio_image_mode'] = $image_mode;
			}
			if ( 'generate' === $image_mode ) {
				$data['ai_image_provider'] = 'contentos';
			}
		} else {
			$image_mode     = $data['costudio_image_mode'] ?? (string) $settings->get( 'costudio_image_mode', 'generate' );
			$image_provider = $data['ai_image_provider'] ?? (string) $settings->get( 'ai_image_provider', '' );
			if ( 'generate' === $image_mode && 'contentos' === $image_provider ) {
				$previous_provider = (string) $settings->get( 'ai_image_provider', '' );
				$data['ai_image_provider'] = ( $previous_provider && 'contentos' !== $previous_provider )
					? $previous_provider
					: '';
			}
		}

		// Language
		if ( $this->was_present( 'default_language' ) ) {
			$data['default_language'] = $in['default_language'];
		}
		if ( $this->was_present( 'default_content_language' ) ) {
			$data['default_content_language'] = $in['default_content_language'];
		}

		// License deactivation is an explicit action. It must run before the
		// activation validator so an empty key is not treated as a broken
		// activation attempt.
		if ( $in['costudio_deactivate_license'] ) {
			$result = Costudio_Standalone_License::deactivate_license();

			if ( ! $result['success'] ) {
				wp_send_json_error( $result['message'] );
				return;
			}

			wp_send_json_success( $result['message'] );
			return;
		}

		// License Key — validated remotely against contentosapp.com.
		if ( $this->was_present( 'costudio_pro_license_key' ) ) {
			$license_key   = $in['costudio_pro_license_key'];
			$license_email = $this->was_present( 'costudio_license_email' )
				? $in['costudio_license_email']
				: '';

			if ( '' === trim( $license_key ) ) {
				wp_send_json_error( __( 'License key is required.', 'contentosapp-studio' ) );
				return;
			}

			if ( '' === $license_email || ! is_email( $license_email ) ) {
				wp_send_json_error( __( 'Enter the purchase email used for this license.', 'contentosapp-studio' ) );
				return;
			}

			$result = Costudio_Standalone_License::activate_license( $license_key, $license_email );

			if ( ! $result['success'] ) {
				wp_send_json_error( $result['message'] );
				return;
			}

			// Persist email separately (activate_license already persists the rest).
			if ( ! empty( $license_email ) ) {
				update_option( 'costudio_license_email', $license_email, false );
			}

			// Return early with the activation message — no need to save settings further.
			wp_send_json_success( $result['message'] );
			return;
		}

		// License Email (when submitted without a key change).
		if ( $this->was_present( 'costudio_license_email' ) ) {
			update_option( 'costudio_license_email', $in['costudio_license_email'], false );
		}

		$settings->set_many( $data );

		$new_content_language = (string) ( $data['default_content_language'] ?? $previous_content_language );
		$prompts_locale       = class_exists( 'Costudio_Agent_Seeder' )
			? (string) costudio_get_option( Costudio_Agent_Seeder::LOCALE_OPTION, '' )
			: '';

		// Check per-agent flags (authoritative) instead of the legacy global flag
		// which may be permanently set from older saves even after a full restore.
		$any_agent_custom = false;
		for ( $i = 1; $i <= 7; $i++ ) {
			if ( '1' === (string) costudio_get_option( sprintf( 'costudio_agent_%02d_is_custom', $i ), '' ) ) {
				$any_agent_custom = true;
				break;
			}
		}

		$sync_confirmed = true;
		if ( $new_content_language !== ''
			&& $new_content_language !== $prompts_locale
			&& ! $any_agent_custom
			&& class_exists( 'Costudio_Agent_Seeder' )
		) {
			$sync_confirmed = Costudio_Agent_Seeder::force_sync_from_vault( $new_content_language );
		}

		// Gate 0-B (2026-08-20): this endpoint's response is a plain string
		// consumed directly as UI text (see costudio-admin-settings-language.js),
		// so a failed sync is folded into the message itself rather than adding
		// a new response field that would break that contract.
		wp_send_json_success(
			$sync_confirmed
				? __( 'Settings saved.', 'contentosapp-studio' )
				: __( 'Settings saved, but the prompt re-sync for the new language could not be confirmed — check Technical Logs.', 'contentosapp-studio' )
		);
	}

	private static function normalize_search_model_for_provider( string $provider, $model ): string {
		$provider = strtolower( trim( $provider ) );
		$raw_model = trim( (string) $model );
		$model     = strtolower( $raw_model );

		switch ( $provider ) {
			case 'exa':
				if ( 0 === strpos( $model, 'exa:' ) ) {
					$model = substr( $model, 4 );
				}
				if ( in_array( $model, array( 'neural', 'keyword' ), true ) ) {
					$model = 'auto';
				}
				return in_array( $model, array( 'auto', 'fast', 'deep' ), true ) ? $model : 'auto';

			case 'tavily':
				return in_array( $model, array( 'basic', 'advanced' ), true ) ? $model : 'advanced';

			case 'serper':
				return in_array( $model, array( 'search', 'news' ), true ) ? $model : 'search';

			case 'gemini':
				return $raw_model;

			default:
				return '';
		}
	}

	private static function default_search_model_for_provider( string $provider ): string {
		switch ( strtolower( trim( $provider ) ) ) {
			case 'exa':
				return 'auto';
			case 'tavily':
				return 'advanced';
			case 'serper':
				return 'search';
			default:
				return '';
		}
	}

	/**
	 * AJAX: Fetch available models from an AI provider.
	 */
	public function ajax_costudio_fetch_models(): void {
		$in = $this->read_request(
			array(
				'provider' => 'text',
				'api_key'  => 'text',
			)
		);

		$provider = $in['provider'];
		$api_key  = $in['api_key'];

		// If masked or empty, load decrypted key from settings.
		if ( empty( $api_key ) || strpos( $api_key, '•' ) !== false ) {
			$settings = costudio_settings();
			$api_key  = $settings ? (string) $settings->get( 'ai_api_key' ) : '';
		}

		if ( empty( $api_key ) ) {
			wp_send_json_error( __( 'Enter an API key first.', 'contentosapp-studio' ) );
		}

		$models = Costudio_Admin_Model_Discovery::fetch_text_models( $provider, $api_key );
		if ( is_wp_error( $models ) ) {
			wp_send_json_error( $models->get_error_message() );
		}

		// Persist models to cache so the settings page can pre-populate without a re-fetch.
		$cache              = get_option( 'costudio_models_cache', array() );
		$cache[ $provider ] = $models;
		update_option( 'costudio_models_cache', $cache, false );

		wp_send_json_success( $models );
	}

	/**
	 * AJAX: Fetch available image models from provider APIs.
	 */
	public function ajax_costudio_fetch_image_models(): void {
		$in = $this->read_request(
			array(
				'provider' => 'text',
				'api_key'  => 'text',
			)
		);

		$provider = $in['provider'];
		$api_key  = $in['api_key'];
		if ( empty( $api_key ) || strpos( $api_key, '•' ) !== false ) {
			$settings = costudio_settings();
			$api_key  = $settings ? (string) $settings->get( 'ai_image_api_key' ) : '';
		}

		$dedup = Costudio_Admin_Model_Discovery::fetch_image_models( $provider, $api_key );
		if ( is_wp_error( $dedup ) ) {
			wp_send_json_error( $dedup->get_error_message() );
		}

		$cache              = get_option( 'costudio_image_models_cache', array() );
		$cache[ $provider ] = $dedup;
		update_option( 'costudio_image_models_cache', $cache, false );

		wp_send_json_success( $dedup );
	}

	/**
	 * AJAX: Fetch available models for search providers.
	 */
	public function ajax_costudio_fetch_search_models(): void {
		$in = $this->read_request(
			array(
				'provider' => 'text',
				'api_key'  => 'text',
			)
		);

		$provider = $in['provider'];
		$api_key  = $in['api_key'];

		// If masked or empty, load decrypted key from settings.
		if ( empty( $api_key ) || strpos( $api_key, '•' ) !== false ) {
			$settings = costudio_settings();
			$api_key  = $settings ? (string) $settings->get( 'search_api_key' ) : '';
		}

		if ( 'gemini' === $provider && empty( $api_key ) ) {
			$settings = costudio_settings();
			$api_key  = $settings ? (string) $settings->get( 'ai_api_key' ) : '';
		}

		$models = Costudio_Admin_Model_Discovery::fetch_search_models( $provider, $api_key );
		if ( is_wp_error( $models ) ) {
			wp_send_json_error( $models->get_error_message() );
		}

		$cache              = get_option( 'costudio_search_models_cache', array() );
		$cache[ $provider ] = $models;
		update_option( 'costudio_search_models_cache', $cache, false );

		wp_send_json_success( $models );
	}

	/**
	 * AJAX: Test connection to an AI provider.
	 */
	public function ajax_costudio_test_ai_connection(): void {
		$in = $this->read_request(
			array(
				'provider' => 'text',
				'api_key'  => 'text',
				'model'    => 'text',
			)
		);
		$this->throttle_diagnostics();

		$provider = $in['provider'];
		$api_key  = $in['api_key'];
		$model    = $in['model'];

		// If masked or empty, load decrypted key from settings.
		if ( empty( $api_key ) || strpos( $api_key, '•' ) !== false ) {
			$settings = costudio_settings();
			$api_key  = $settings ? (string) $settings->get( 'ai_api_key' ) : '';
		}

		if ( empty( $api_key ) ) {
			wp_send_json_error( __( 'Enter an API key first.', 'contentosapp-studio' ) );
		}

		switch ( $provider ) {
			case 'gemini':
				// Lightweight ping — list models (1 result).
				$url = 'https://generativelanguage.googleapis.com/v1beta/models?key=' . rawurlencode( $api_key ) . '&pageSize=1';
				$res = wp_remote_get( $url, array( 'timeout' => 15 ) );
				if ( is_wp_error( $res ) ) {
					wp_send_json_error( $res->get_error_message() );
				}
				$code = (int) wp_remote_retrieve_response_code( $res );
				if ( $code === 200 ) {
					wp_send_json_success( __( '✓ Connected! Gemini API key is valid.', 'contentosapp-studio' ) );
				}
				$body = json_decode( wp_remote_retrieve_body( $res ), true );
				wp_send_json_error( $body['error']['message'] ?? "HTTP {$code}" );
				break;

			case 'openai':
				$res = wp_remote_get(
					'https://api.openai.com/v1/models',
					array(
						'timeout' => 15,
						'headers' => array( 'Authorization' => 'Bearer ' . $api_key ),
					)
				);
				if ( is_wp_error( $res ) ) {
					wp_send_json_error( $res->get_error_message() );
				}
				$code = (int) wp_remote_retrieve_response_code( $res );
				if ( $code === 200 ) {
					wp_send_json_success( __( '✓ Connected! OpenAI API key is valid.', 'contentosapp-studio' ) );
				}
				$body = json_decode( wp_remote_retrieve_body( $res ), true );
				wp_send_json_error( $body['error']['message'] ?? "HTTP {$code}" );
				break;

			case 'claude':
				// Lightweight ping via models list — no tokens consumed.
				$res = wp_remote_get(
					'https://api.anthropic.com/v1/models',
					array(
						'timeout' => 15,
						'headers' => array(
							'x-api-key'         => $api_key,
							'anthropic-version' => '2023-06-01',
						),
					)
				);
				if ( is_wp_error( $res ) ) {
					wp_send_json_error( $res->get_error_message() );
				}
				$code = (int) wp_remote_retrieve_response_code( $res );
				if ( $code === 200 ) {
					wp_send_json_success( __( '✓ Connected! Anthropic API key is valid.', 'contentosapp-studio' ) );
				}
				$body = json_decode( wp_remote_retrieve_body( $res ), true );
				wp_send_json_error( $body['error']['message'] ?? "HTTP {$code}" );
				break;

			default:
				wp_send_json_error( __( 'Unknown provider.', 'contentosapp-studio' ) );
		}
	}

	/**
	 * AJAX: Test image provider connection.
	 */
	public function ajax_costudio_test_image_connection(): void {
		$in = $this->read_request(
			array(
				'provider' => 'text',
				'api_key'  => 'text',
				'model'    => 'text',
			)
		);
		$this->throttle_diagnostics();

		$provider = $in['provider'];
		$api_key  = $in['api_key'];
		$model    = $in['model'];
		if ( empty( $api_key ) || strpos( $api_key, '•' ) !== false ) {
			$settings = costudio_settings();
			$api_key  = $settings ? (string) $settings->get( 'ai_image_api_key' ) : '';
			if ( '' === $model ) {
				$model = $settings ? (string) $settings->get( 'ai_image_model' ) : '';
			}
		}
		if ( empty( $api_key ) ) {
			wp_send_json_error( __( 'Enter an API key first.', 'contentosapp-studio' ) );
		}

		switch ( $provider ) {
			case 'gemini_imagen':
				if ( '' === $model ) {
					$model = class_exists( 'Costudio_Image_Generator' )
						? Costudio_Image_Generator::IMAGEN_DEFAULT_MODEL
						: 'gemini-3.1-flash-image';
				}

				$model = Costudio_Admin_Model_Discovery::normalize_gemini_model_id( $model );
				$info  = Costudio_Admin_Model_Discovery::fetch_gemini_model( $api_key, $model );
				if ( is_wp_error( $info ) ) {
					wp_send_json_error( $info->get_error_message() );
				}

				if ( ! Costudio_Admin_Model_Discovery::is_gemini_image_generation_model( $info ) ) {
					wp_send_json_error(
						sprintf(
							/* translators: %s: selected provider model id */
							__( 'Selected model is not an image generation model: %s', 'contentosapp-studio' ),
							$model
						)
					);
				}

				wp_send_json_success(
					sprintf(
						/* translators: %s: selected provider model id */
						__( '✓ Connected! Gemini image key and model %s are valid.', 'contentosapp-studio' ),
						$model
					)
				);
				break;

			case 'openai_dalle':
				$res = wp_remote_get(
					'https://api.openai.com/v1/models',
					array(
						'timeout' => 15,
						'headers' => array( 'Authorization' => 'Bearer ' . $api_key ),
					)
				);
				if ( is_wp_error( $res ) ) {
					wp_send_json_error( $res->get_error_message() );
				}
				$code = (int) wp_remote_retrieve_response_code( $res );
				if ( $code === 200 ) {
					wp_send_json_success( __( '✓ Connected! OpenAI image key is valid.', 'contentosapp-studio' ) );
				}
				$body = json_decode( wp_remote_retrieve_body( $res ), true );
				wp_send_json_error( $body['error']['message'] ?? "HTTP {$code}" );
				break;

			default:
				wp_send_json_error( __( 'Unknown image provider.', 'contentosapp-studio' ) );
		}
	}

	/**
	 * AJAX: Test web search connection.
	 */
	public function ajax_costudio_test_search_connection(): void {
		$in = $this->read_request(
			array(
				'target'    => 'key',
				'query'     => 'text',
				'provider'  => 'text',
				'api_key'   => 'text',
				'grounding' => 'bool',
			)
		);
		$this->throttle_diagnostics();

		// Diagnostic branch: probe the ContentOS native /ai/search endpoint
		// directly. Returns HTTP code + body snippet so the operator can see
		// what Cloudflare/origin is actually serving when grounding silently
		// fails inside the pipeline. Trigger from the browser console:
		//   jQuery.post(ajaxurl,{action:'costudio_test_search_connection',
		//     nonce:costudioAdmin.nonce, target:'contentos_native', query:'test'})
		//     .done(r => console.log(r));
		if ( 'contentos_native' === $in['target'] ) {
			if ( ! class_exists( 'Costudio_Search_Engine_ContentOS' ) ) {
				wp_send_json_error( 'Costudio_Search_Engine_ContentOS class not loaded.' );
			}
			$query  = $in['query'] ?: 'test';
			$engine = new Costudio_Search_Engine_ContentOS();
			$t0     = microtime( true );
			$result = $engine->search( $query );
			$ms     = (int) round( ( microtime( true ) - $t0 ) * 1000 );
			wp_send_json_success( array(
				'latency_ms'   => $ms,
				'ok'           => ! empty( $result['ok'] ),
				'sources'      => isset( $result['sources'] ) && is_array( $result['sources'] ) ? count( $result['sources'] ) : 0,
				'model_used'   => (string) ( $result['model_used'] ?? '' ),
				'error'        => (string) ( $result['error'] ?? '' ),
			) );
		}

		$provider  = $in['provider'];
		$api_key   = $in['api_key'];
		$grounding = $in['grounding'];

		if ( $grounding ) {
			$settings = costudio_settings();
			$gem_key  = $settings ? (string) $settings->get( 'ai_api_key' ) : '';
			if ( empty( $gem_key ) ) {
				wp_send_json_error( __( 'To use grounding, configure the AI Provider API key first.', 'contentosapp-studio' ) );
			}
			$url = 'https://generativelanguage.googleapis.com/v1beta/models?key=' . rawurlencode( $gem_key ) . '&pageSize=1';
			$res = wp_remote_get( $url, array( 'timeout' => 15 ) );
			if ( is_wp_error( $res ) ) {
				wp_send_json_error( $res->get_error_message() );
			}
			$code = (int) wp_remote_retrieve_response_code( $res );
			if ( $code === 200 ) {
				wp_send_json_success( __( '✓ Connected! Google Search Grounding is ready.', 'contentosapp-studio' ) );
			}
			$body = json_decode( wp_remote_retrieve_body( $res ), true );
			wp_send_json_error( $body['error']['message'] ?? "HTTP {$code}" );
		}

		if ( empty( $api_key ) || strpos( $api_key, '•' ) !== false ) {
			$settings = costudio_settings();
			$api_key  = $settings ? (string) $settings->get( 'search_api_key' ) : '';

			if ( 'gemini' === $provider && empty( $api_key ) ) {
				$api_key = $settings ? (string) $settings->get( 'ai_api_key' ) : '';
			}
		}

		if ( empty( $api_key ) ) {
			wp_send_json_error( __( 'Enter the search API key first.', 'contentosapp-studio' ) );
		}

		switch ( $provider ) {
			case 'tavily':
				$res = wp_remote_post(
					'https://api.tavily.com/search',
					array(
						'timeout' => 15,
						'headers' => array( 'Content-Type' => 'application/json' ),
						'body'    => wp_json_encode( array( 'api_key' => $api_key, 'query' => 'test' ) ),
					)
				);
				break;
			case 'exa':
				$res = wp_remote_post(
					'https://api.exa.ai/search',
					array(
						'timeout' => 15,
						'headers' => array(
							'x-api-key'    => $api_key,
							'Content-Type' => 'application/json',
						),
						'body'    => wp_json_encode(
							array(
								'query'      => 'test',
								'type'       => 'auto',
								'numResults' => 1,
								'contents'   => array(
									'highlights' => true,
									'text'       => array( 'maxCharacters' => 250 ),
								),
							)
						),
					)
				);
				break;
			case 'serper':
				$res = wp_remote_post(
					'https://google.serper.dev/search',
					array(
						'timeout' => 15,
						'headers' => array(
							'X-API-KEY'    => $api_key,
							'Content-Type' => 'application/json',
						),
						'body'    => wp_json_encode( array( 'q' => 'test' ) ),
					)
				);
				break;
			case 'gemini':
				// Gemini grounding uses the Generative Language API.
				$url = 'https://generativelanguage.googleapis.com/v1beta/models?key=' . rawurlencode( $api_key ) . '&pageSize=1';
				$res = wp_remote_get( $url, array( 'timeout' => 15 ) );
				break;
			default:
				wp_send_json_error( __( 'Unknown search provider.', 'contentosapp-studio' ) );
				return;
		}

		if ( is_wp_error( $res ) ) {
			wp_send_json_error( $res->get_error_message() );
		}
		$code = (int) wp_remote_retrieve_response_code( $res );
		if ( $code >= 200 && $code < 300 ) {
			wp_send_json_success( __( '✓ Connected! Search provider key is valid.', 'contentosapp-studio' ) );
		}
		$body = json_decode( wp_remote_retrieve_body( $res ), true );
		wp_send_json_error( $body['error']['message'] ?? $body['message'] ?? "HTTP {$code}" );
	}

	// ── Model discovery helpers ───────────────────────────────────────────────

	/**
	 * Fetch all Gemini model definitions (paginated) from the Generative Language API.
	 *
	 * @return array<int, array<string, mixed>>|\WP_Error
	 */
	private function costudio_fetch_gemini_models_all_pages( string $api_key ) {
		return Costudio_Admin_Model_Discovery::fetch_gemini_models_all_pages( $api_key );
	}

	/**
	 * Keep only models suitable for long-form text (generateContent), excluding embeddings/TTS/legacy.
	 */
	private function costudio_filter_gemini_text_generation_models( array $raw_models ): array {
		return Costudio_Admin_Model_Discovery::filter_gemini_text_generation_models( $raw_models );
	}

	private function costudio_is_gemini_article_text_model( array $m ): bool {
		return Costudio_Admin_Model_Discovery::is_gemini_article_text_model( $m );
	}

	/**
	 * IDs compatible with Chat Completions (Costudio_OpenAI_Adapter).
	 */
	private function costudio_filter_openai_chat_text_models( array $ids ): array {
		return Costudio_Admin_Model_Discovery::filter_openai_chat_text_models( $ids );
	}

	private function costudio_is_openai_chat_text_model( string $id ): bool {
		return Costudio_Admin_Model_Discovery::is_openai_chat_text_model( $id );
	}

	/**
	 * Rows from GET /v1/models — keep Claude models usable with Messages API (text generation).
	 */
	private function costudio_filter_claude_messages_text_models( array $data_rows ): array {
		return Costudio_Admin_Model_Discovery::filter_claude_messages_text_models( $data_rows );
	}

	private function costudio_is_claude_messages_text_model( string $id ): bool {
		return Costudio_Admin_Model_Discovery::is_claude_messages_text_model( $id );
	}
}
