<?php
/**
 * Aimogen Pro / Aiomatic - WordPress 7 AI Connectors integration.
 *
 * WordPress 7+ ships an `/wp-admin/options-connectors.php` page backed by
 * `WP_Connector_Registry`. The page is a sealed React SPA: no HTML filter,
 * only the registry. What this class does:
 *
 *   1. Shows a banner on the Connectors page offering to let Aimogen Pro
 *      manage the AI connectors (opt-in).
 *   2. When opted in ("managed"): on `wp_connectors_init`, registers one
 *      connector per Aimogen-supported AI provider, replacing the WP core
 *      defaults so the cards no longer push the standalone provider plugins.
 *   3. Bridges `connectors_ai_{id}_api_key` <-> Aimogen Pro API keys in both
 *      directions, so keys can be managed from either screen.
 *   4. Declares credentials present/valid to the WP "AI" feature plugin
 *      (`wpai_*` filters), so WordPress AI features light up when Aimogen
 *      providers are configured - including providers WP core has no
 *      AiClient adapter for (Groq, NVIDIA, xAI, OpenRouter, Ollama, ...).
 *
 * Modes: 'observe' (default, banner offers takeover), 'managed' (Aimogen
 * owns the registry and bridges keys), 'off' (integration disabled).
 * The banner can also be dismissed without changing the mode.
 */

defined( 'ABSPATH' ) || exit;

class Aiomatic_WP_AI_Connectors {

	const OPTION_MODE      = 'aiomatic_wp_ai_connectors_mode';
	const OPTION_DISMISSED = 'aiomatic_wp_ai_connectors_banner_dismissed';
	const OPTION_MODELS    = 'aiomatic_wp_ai_connectors_models';
	const MODELS_CACHE_KEY = 'aiomatic_wp_ai_connectors_model_list_v3';

	/** Reentrance guard for the two-way key bridge. */
	private static $syncing = false;

	/**
	 * Providers managed by Aimogen Pro.
	 *
	 * 'key'     => the key inside the `aiomatic_Main_Settings` option array.
	 * 'keyless' => providers with no credential at all (rare; do not use
	 *              for Ollama — it needs a server URL, registered as api_key).
	 */
	private static $providers = array(
		'openai' => array(
			'name'        => 'OpenAI',
			'description' => 'Text and image generation with GPT AI models, managed by Aimogen Pro.',
			'key'         => 'app_id',
			'url'         => 'https://platform.openai.com/api-keys',
		),
		'anthropic' => array(
			'name'        => 'Anthropic',
			'description' => 'Text generation with Claude, managed by Aimogen Pro.',
			'key'         => 'app_id_claude',
			'url'         => 'https://console.anthropic.com/settings/keys',
		),
		'google' => array(
			'name'        => 'Google',
			'description' => 'Text and image generation with Gemini and Imagen, managed by Aimogen Pro.',
			'key'         => 'app_id_google',
			'url'         => 'https://aistudio.google.com/api-keys',
		),
		'openrouter' => array(
			'name'        => 'OpenRouter',
			'description' => 'Hundreds of models through one API, managed by Aimogen Pro.',
			'key'         => 'app_id_openrouter',
			'url'         => 'https://openrouter.ai/keys',
		),
		'perplexity' => array(
			'name'        => 'Perplexity',
			'description' => 'Web-grounded text generation with Sonar, managed by Aimogen Pro.',
			'key'         => 'app_id_perplexity',
			'url'         => 'https://www.perplexity.ai/settings/api',
		),
		'groq' => array(
			'name'        => 'Groq',
			'description' => 'Ultra-fast open model inference, managed by Aimogen Pro.',
			'key'         => 'app_id_groq',
			'url'         => 'https://console.groq.com/keys',
		),
		'nvidia' => array(
			'name'        => 'NVIDIA',
			'description' => 'NVIDIA NIM hosted models, managed by Aimogen Pro.',
			'key'         => 'app_id_nvidia',
			'url'         => 'https://build.nvidia.com/',
		),
		'xai' => array(
			'name'        => 'xAI',
			'description' => 'Text and image generation with Grok, managed by Aimogen Pro.',
			'key'         => 'app_id_xai',
			'url'         => 'https://console.x.ai/',
		),
		'huggingface' => array(
			'name'        => 'Hugging Face',
			'description' => 'Open models on Hugging Face Inference, managed by Aimogen Pro.',
			'key'         => 'app_id_huggingface',
			'url'         => 'https://huggingface.co/settings/tokens',
		),
		'ollama' => array(
			'name'        => 'Ollama',
			'description' => 'Local, self-hosted models via Ollama, managed by Aimogen Pro.',
			'key'         => 'ollama_url',
		),
	);

	public static function init(): void {
		if ( ! class_exists( 'WP_Connector_Registry' ) ) {
			return;
		}

		// Banner + mode toggle (admin UI).
		add_action( 'admin_footer', array( self::class, 'maybe_render_banner' ) );
		add_action( 'wp_ajax_aiomatic_wp_ai_connectors_set_mode', array( self::class, 'ajax_set_mode' ) );

		// Model selection: AJAX endpoints for the banner picker, and
		// preference filters so WP AI features actually use the choice.
		add_action( 'wp_ajax_aiomatic_wp_ai_connectors_get_models', array( self::class, 'ajax_get_models' ) );
		add_action( 'wp_ajax_aiomatic_wp_ai_connectors_set_model', array( self::class, 'ajax_set_model' ) );
		add_filter( 'wpai_preferred_text_models', array( self::class, 'prefer_selected_text_model' ), 5 );
		add_filter( 'wpai_preferred_image_models', array( self::class, 'prefer_selected_image_model' ), 5 );

		// Register AiClient provider adapters for Aimogen's providers that
		// have no dedicated "AI Provider" plugin (Groq, NVIDIA, xAI,
		// OpenRouter, Perplexity, Hugging Face, Ollama, Google, ...). They
		// all expose OpenAI-compatible APIs, so WP AI features can route to
		// them directly. Priority 5 mirrors the official provider plugins
		// and runs before core pushes connector keys at init@20.
		add_action( 'init', array( self::class, 'register_ai_client_providers' ), 5 );

		// Registry takeover. Late priority so we win over core defaults and
		// the standalone `ai-provider-for-*` plugins.
		add_action( 'wp_connectors_init', array( self::class, 'customize_registry' ), 1000 );

		// Run after every other plugin had its chance to register, so we can
		// detect competing "connector manager" plugins that took over.
		add_action( 'wp_connectors_init', array( self::class, 'detect_foreign_managers' ), PHP_INT_MAX );

		// Key bridge: WP Connectors options -> Aimogen settings.
		add_action( 'updated_option', array( self::class, 'maybe_bridge_connector_key' ), 10, 3 );
		add_action( 'added_option', array( self::class, 'maybe_bridge_added_connector_key' ), 10, 2 );

		// Key bridge: Aimogen settings -> WP Connectors options.
		add_action( 'update_option_aiomatic_Main_Settings', array( self::class, 'mirror_settings_to_connectors' ), 10, 2 );
		add_action( 'add_option_aiomatic_Main_Settings', array( self::class, 'mirror_added_settings_to_connectors' ), 10, 2 );

		// Reconcile on admin loads so the Connectors page reflects the current
		// Aimogen state even if keys were edited elsewhere (WP-CLI, import...).
		add_action( 'admin_init', array( self::class, 'initial_sync' ), 99 );

		// Flip `isConnected` on the Connectors page for managed providers, so
		// cards show as connected even when the AiClient registry has no
		// adapter for them (OpenRouter, Groq, NVIDIA, Ollama, ...).
		add_filter( 'script_module_data_options-connectors-wp-admin', array( self::class, 'mark_connectors_connected' ), 1000 );

		// Take over the WP "AI" feature plugin's credentials gate: when
		// managed, Aimogen is the source of truth for usable providers, and
		// the live `wp_ai_client_prompt('Test')` round-trip would falsely
		// fail for providers WP core ships no AiClient adapter for.
		add_filter( 'wpai_has_ai_credentials', array( self::class, 'declare_credentials_present' ), 1000, 1 );
		add_filter( 'wpai_pre_has_valid_credentials_check', array( self::class, 'declare_credentials_valid' ), 1000, 1 );

		// Per-connector configured status (used by the AI Status dashboard widget).
		foreach ( array_keys( self::$providers ) as $connector_id ) {
			add_filter( 'wpai_is_' . $connector_id . '_connector_configured', array( self::class, 'declare_connector_configured' ), 1000, 2 );
		}

		// Reliability: core wires the AI Client to the (usually non-persistent)
		// object cache, so every request re-fetches provider model catalogs
		// over HTTP before deciding whether generation is "supported". A slow
		// or failed fetch surfaces as an `unsupported_model` error in the
		// editor. When managed, swap in a transient-backed persistent cache
		// and keep the catalogs warm in the background.
		if ( self::is_managed() && class_exists( 'WordPress\\AiClient\\AiClient' )
			&& interface_exists( 'WordPress\\AiClientDependencies\\Psr\\SimpleCache\\CacheInterface' ) ) {
			try {
				\WordPress\AiClient\AiClient::setCache( new Aiomatic_WP_AI_Client_Transient_Cache() );
			} catch ( Throwable $e ) {
				// Core keeps its default cache; behavior is unchanged.
			}

			add_action( 'init', array( self::class, 'schedule_cache_warmup' ) );
			add_action( 'aiomatic_wp_ai_connectors_warm_cache', array( self::class, 'warm_ai_client_cache' ) );
		}
	}

	/* ─────────────────────────────────────────────────────────────────────
	 * AiClient provider adapters.
	 * ──────────────────────────────────────────────────────────────────── */

	/**
	 * Loads and registers Aimogen's AiClient provider adapters (managed mode
	 * only). Dedicated provider plugins always take precedence; the adapters
	 * only fill in providers that would otherwise be unusable by WP AI.
	 */
	public static function register_ai_client_providers(): void {
		if ( ! self::is_managed() || ! class_exists( 'WordPress\\AiClient\\AiClient' ) ) {
			return;
		}

		require_once __DIR__ . '/aiomatic-wpai-providers.php';

		Aiomatic_WPAI_Providers::register();
	}

	/**
	 * The first usable credential line for a provider from Aimogen's settings
	 * (Aimogen supports multi-line round-robin keys; AiClient wants one).
	 */
	public static function aimogen_credential( string $connector_id ): string {
		$key = self::$providers[ $connector_id ]['key'] ?? '';

		if ( $key === '' ) {
			return '';
		}

		$settings = self::settings();

		return self::first_line( isset( $settings[ $key ] ) ? (string) $settings[ $key ] : '' );
	}

	/* ─────────────────────────────────────────────────────────────────────
	 * AI Client cache warmup.
	 * ──────────────────────────────────────────────────────────────────── */

	public static function schedule_cache_warmup(): void {
		if ( ! wp_next_scheduled( 'aiomatic_wp_ai_connectors_warm_cache' ) ) {
			wp_schedule_event( time() + MINUTE_IN_SECONDS, 'twicedaily', 'aiomatic_wp_ai_connectors_warm_cache' );
		}
	}

	/**
	 * Fetches provider model catalogs in the background so editor requests
	 * hit the persistent cache instead of making live HTTP round-trips.
	 */
	public static function warm_ai_client_cache(): void {
		if ( ! self::is_managed() || ! class_exists( 'WordPress\\AiClient\\AiClient' ) ) {
			return;
		}

		try {
			$registry = \WordPress\AiClient\AiClient::defaultRegistry();

			foreach ( $registry->getRegisteredProviderIds() as $provider_id ) {
				try {
					if ( ! $registry->isProviderConfigured( $provider_id ) ) {
						continue;
					}

					$class_name = $registry->getProviderClassName( $provider_id );
					$class_name::modelMetadataDirectory()->listModelMetadata();
				} catch ( Throwable $e ) {
					if ( function_exists( 'aiomatic_log_to_file' ) ) {
						aiomatic_log_to_file( 'WP AI Connector cache warmup failed for ' . $provider_id . ': ' . $e->getMessage() );
					}
				}
			}
		} catch ( Throwable $e ) {
			if ( function_exists( 'aiomatic_log_to_file' ) ) {
				aiomatic_log_to_file( 'WP AI Connector cache warmup failed: ' . $e->getMessage() );
			}
		}
	}

	/* ─────────────────────────────────────────────────────────────────────
	 * Registry customization (managed mode).
	 * ──────────────────────────────────────────────────────────────────── */

	public static function customize_registry( WP_Connector_Registry $registry ): void {
		if ( ! self::is_managed() ) {
			return;
		}

		foreach ( self::$providers as $connector_id => $config ) {
			try {
				if ( $registry->is_registered( $connector_id ) ) {
					$registry->unregister( $connector_id );
				}

				if ( ! empty( $config['keyless'] ) ) {
					$auth = array( 'method' => 'none' );
				} else {
					// Mirror what WP core sets on its own default connectors:
					// without setting_name the WP AI plugin's credential check
					// skips the connector entirely; constant/env names allow
					// keys via wp-config.php or the server environment.
					$sanitized_id  = str_replace( '-', '_', $connector_id );
					$constant_case = strtoupper( $sanitized_id ) . '_API_KEY';
					$auth          = array(
						'method'        => 'api_key',
						'setting_name'  => self::connector_option_name( $connector_id ),
						'constant_name' => $constant_case,
						'env_var_name'  => $constant_case,
					);
					if ( ! empty( $config['url'] ) ) {
						$auth['credentials_url'] = $config['url'];
					} elseif ( $connector_id === 'ollama' ) {
						$auth['credentials_url'] = admin_url( 'admin.php?page=aiomatic_admin_settings#tab-2' );
					}
				}

				$registry->register(
					$connector_id,
					array(
						'name'           => $config['name'] . ' via Aimogen Pro',
						'description'    => self::describe_provider( $connector_id, $config ),
						'type'           => 'ai_provider',
						'authentication' => $auth,
						'plugin'         => array(
							'file'      => self::plugin_file(),
							'is_active' => '__return_true',
						),
					)
				);
			} catch ( Throwable $e ) {
				if ( function_exists( 'aiomatic_log_to_file' ) ) {
					aiomatic_log_to_file( 'WP AI Connector registration failed for ' . $connector_id . ': ' . $e->getMessage() );
				}
			}
		}
	}

	private static function describe_provider( string $connector_id, array $config ): string {
		if ( self::provider_has_credentials( $connector_id ) ) {
			if ( $connector_id === 'ollama' ) {
				return __( 'Using the Ollama server URL configured in Aimogen Pro.', 'aiomatic-automatic-ai-content-writer' );
			}

			/* translators: %s: AI provider name. */
			return sprintf( __( 'Using the %s credentials configured in Aimogen Pro.', 'aiomatic-automatic-ai-content-writer' ), $config['name'] );
		}

		if ( $connector_id === 'ollama' ) {
			return __( 'Set your Ollama server URL in Aimogen Pro to connect local models.', 'aiomatic-automatic-ai-content-writer' );
		}

		return $config['description'];
	}

	/* ─────────────────────────────────────────────────────────────────────
	 * Key bridge: WP Connectors options -> Aimogen settings.
	 * ──────────────────────────────────────────────────────────────────── */

	public static function maybe_bridge_added_connector_key( string $option, $value ): void {
		self::maybe_bridge_connector_key( $option, null, $value );
	}

	public static function maybe_bridge_connector_key( string $option, $old_value, $value ): void {
		if ( self::$syncing || ! self::is_managed() ) {
			return;
		}

		$connector_id = self::connector_id_from_option_name( $option );
		if ( $connector_id === null ) {
			return;
		}

		$config = self::$providers[ $connector_id ];
		if ( ! empty( $config['keyless'] ) ) {
			return;
		}

		$new_key = is_scalar( $value ) ? trim( (string) $value ) : '';

		$settings = self::settings();
		$current  = isset( $settings[ $config['key'] ] ) ? (string) $settings[ $config['key'] ] : '';

		// Never let an empty connectors value wipe an existing Aimogen key.
		// WP core's REST validation empties `connectors_ai_*` options for
		// providers the AiClient registry has no adapter for; bridging that
		// wipe would destroy the user's key in both places. Aimogen stays the
		// source of truth and `initial_sync()` restores the connectors option
		// on the next admin load.
		if ( $new_key === '' && trim( $current ) !== '' ) {
			return;
		}

		if ( $new_key === self::first_line( $current ) ) {
			return;
		}

		self::$syncing = true;

		// Aimogen supports multiple newline-separated keys per provider
		// (round-robin). Only replace the first key; keep any extra lines.
		$settings[ $config['key'] ] = self::replace_first_line( $current, $new_key );
		update_option( 'aiomatic_Main_Settings', $settings );

		self::$syncing = false;

		delete_transient( self::MODELS_CACHE_KEY );
	}

	/* ─────────────────────────────────────────────────────────────────────
	 * Key bridge: Aimogen settings -> WP Connectors options.
	 * ──────────────────────────────────────────────────────────────────── */

	public static function mirror_added_settings_to_connectors( $option, $value ): void {
		self::mirror_settings_to_connectors( array(), $value );
	}

	public static function mirror_settings_to_connectors( $old_value, $new_value ): void {
		if ( self::$syncing || ! self::is_managed() || ! is_array( $new_value ) ) {
			return;
		}

		self::$syncing = true;

		foreach ( self::$providers as $connector_id => $config ) {
			if ( ! empty( $config['keyless'] ) ) {
				continue;
			}

			$key         = isset( $new_value[ $config['key'] ] ) ? self::first_line( (string) $new_value[ $config['key'] ] ) : '';
			$option_name = self::connector_option_name( $connector_id );

			if ( get_option( $option_name, '' ) !== $key ) {
				update_option( $option_name, $key, false );
			}
		}

		self::$syncing = false;

		delete_transient( self::MODELS_CACHE_KEY );
	}

	/** One-shot reconcile from Aimogen settings -> WP Connectors options. */
	public static function initial_sync(): void {
		if ( ! self::is_managed() ) {
			return;
		}

		self::mirror_settings_to_connectors( array(), self::settings() );
		self::approve_known_callers();
	}

	/**
	 * Pre-approves trusted callers in the WP AI plugin's Connector Approval
	 * experiment for the providers Aimogen manages.
	 *
	 * The experiment's HTTP guard attributes outbound requests to a connector
	 * by matching the credential in the request against the `connectors_ai_*`
	 * options. Because we mirror Aimogen's keys into those options, both the
	 * WP AI plugin's features and Aimogen's own API calls would be blocked
	 * until the admin manually approves each pair. Managed mode is an
	 * explicit opt-in to run WordPress AI through Aimogen's providers, so
	 * these two callers are approved automatically; any other plugin using
	 * the credentials still goes through the normal approval flow.
	 */
	public static function approve_known_callers(): void {
		if ( ! self::is_managed() ) {
			return;
		}

		// Only relevant when the approval experiment is actually loaded.
		if ( ! class_exists( 'WordPress\\AI\\Connector_Approval\\Http_Guard' ) ) {
			return;
		}

		$callers = (array) apply_filters(
			'aiomatic_wp_ai_connectors_approved_callers',
			array( 'ai/ai.php', self::plugin_file() )
		);

		$connector_ids = array_keys( self::$providers );

		$approvals = get_option( 'wpai_connector_approvals', array() );
		if ( ! is_array( $approvals ) ) {
			$approvals = array();
		}

		$changed = false;
		foreach ( $callers as $basename ) {
			if ( ! is_string( $basename ) || $basename === '' ) {
				continue;
			}
			foreach ( $connector_ids as $connector_id ) {
				if ( empty( $approvals[ $basename ][ $connector_id ] ) ) {
					$approvals[ $basename ][ $connector_id ] = true;
					$changed                                 = true;
				}
			}
		}

		if ( $changed ) {
			update_option( 'wpai_connector_approvals', $approvals, false );
		}

		// Clear matching entries from the pending-approval queue.
		$pending = get_option( 'wpai_connector_approval_pending', array() );
		if ( is_array( $pending ) && ! empty( $pending ) ) {
			$filtered = array();
			foreach ( $pending as $key => $entry ) {
				$caller    = is_array( $entry ) ? (string) ( $entry['caller_basename'] ?? '' ) : '';
				$connector = is_array( $entry ) ? (string) ( $entry['connector_id'] ?? '' ) : '';
				if ( in_array( $caller, $callers, true ) && in_array( $connector, $connector_ids, true ) ) {
					continue;
				}
				$filtered[ $key ] = $entry;
			}
			if ( count( $filtered ) !== count( $pending ) ) {
				update_option( 'wpai_connector_approval_pending', $filtered, false );
			}
		}
	}

	/* ─────────────────────────────────────────────────────────────────────
	 * Competing connector-manager detection.
	 * ──────────────────────────────────────────────────────────────────── */

	/** Names of other plugins currently managing connectors, found in the registry. */
	private static $registry_managers = array();

	/**
	 * Plugins known to take over the WP Connectors registry the same way we
	 * do, with the option that holds their mode. Setting the option to
	 * 'observe' is the same action as clicking "Stop" in their own banner.
	 */
	private static function known_managers(): array {
		return (array) apply_filters(
			'aiomatic_wp_ai_connectors_known_managers',
			array(
				'aipkit'    => array(
					'label'  => 'AI Power / AI Puffer',
					'option' => 'aipkit_wp_ai_client_gateway_mode',
				),
				'ai-engine' => array(
					'label'  => 'AI Engine',
					'option' => 'mwai_wpai_connectors_mode',
				),
			)
		);
	}

	/**
	 * Inspects the final registry state (after all plugins registered) and
	 * records foreign managers. Plugins of this kind brand their cards as
	 * "<Provider> via <Plugin>", so a foreign "via" suffix on an AI provider
	 * connector means another plugin took over - including overriding cards
	 * we registered ourselves earlier in the same action.
	 */
	public static function detect_foreign_managers( WP_Connector_Registry $registry ): void {
		$found = array();

		foreach ( $registry->get_all_registered() as $connector_id => $connector ) {
			if ( ( $connector['type'] ?? '' ) !== 'ai_provider' ) {
				continue;
			}

			if ( preg_match( '/ via (.+)$/u', (string) $connector['name'], $m ) && $m[1] !== 'Aimogen Pro' ) {
				$found[ $m[1] ] = true;
			}
		}

		self::$registry_managers = array_keys( $found );
	}

	/**
	 * All competing managers currently active: known plugins whose mode
	 * option is 'managed', plus anything detected in the registry itself.
	 *
	 * @return array<string, string> Manager labels keyed by detection id.
	 */
	public static function other_managers(): array {
		$others = array();

		foreach ( self::known_managers() as $id => $manager ) {
			if ( empty( $manager['option'] ) ) {
				continue;
			}
			if ( get_option( $manager['option'] ) === 'managed' ) {
				$others[ $id ] = $manager['label'] ?? $id;
			}
		}

		foreach ( self::$registry_managers as $name ) {
			$known = false;
			foreach ( $others as $label ) {
				if ( stripos( $label, $name ) !== false || stripos( $name, $label ) !== false ) {
					$known = true;
					break;
				}
			}
			if ( ! $known ) {
				$others[ 'registry:' . $name ] = $name;
			}
		}

		return $others;
	}

	/**
	 * Switches known competing managers back to 'observe' mode (equivalent to
	 * the "Stop" action in their own banners). Called when the user
	 * explicitly asks Aimogen Pro to take over, so the plugins stop fighting
	 * over the registry. Their own settings and API keys are not touched.
	 */
	private static function demote_other_managers(): void {
		if ( ! apply_filters( 'aiomatic_wp_ai_connectors_demote_competitors', true ) ) {
			return;
		}

		foreach ( self::known_managers() as $manager ) {
			if ( empty( $manager['option'] ) ) {
				continue;
			}
			if ( get_option( $manager['option'] ) === 'managed' ) {
				update_option( $manager['option'], 'observe', false );
			}
		}
	}

	/* ─────────────────────────────────────────────────────────────────────
	 * Model selection for WordPress AI features.
	 *
	 * WP AI features pick their model from a preference list (see the
	 * `wpai_preferred_*_models` filters in the WP "AI" plugin). When the
	 * user selects a model in our banner, we prepend it to those lists so
	 * it is tried first. Only providers with an AiClient adapter can
	 * actually serve WP AI requests, so the picker is populated from the
	 * live AiClient registry - configured providers and their real model
	 * catalogs - not from Aimogen's own provider list.
	 * ──────────────────────────────────────────────────────────────────── */

	/** @return array{text?: array{0: string, 1: string}, image?: array{0: string, 1: string}} */
	private static function selected_models(): array {
		$selection = get_option( self::OPTION_MODELS, array() );
		if ( ! is_array( $selection ) ) {
			return array();
		}

		$valid = array();
		foreach ( array( 'text', 'image' ) as $type ) {
			if ( isset( $selection[ $type ] ) && is_array( $selection[ $type ] )
				&& ! empty( $selection[ $type ][0] ) && ! empty( $selection[ $type ][1] ) ) {
				$provider_id = (string) $selection[ $type ][0];
				if ( ! self::provider_is_connected( $provider_id ) ) {
					continue;
				}
				$valid[ $type ] = array( $provider_id, (string) $selection[ $type ][1] );
			}
		}

		return $valid;
	}

	public static function prefer_selected_text_model( $models ): array {
		return self::prepend_selected_model( is_array( $models ) ? $models : array(), 'text' );
	}

	public static function prefer_selected_image_model( $models ): array {
		return self::prepend_selected_model( is_array( $models ) ? $models : array(), 'image' );
	}

	private static function prepend_selected_model( array $models, string $type ): array {
		if ( ! self::is_managed() ) {
			return $models;
		}

		$selection = self::selected_models();
		if ( empty( $selection[ $type ] ) ) {
			return $models;
		}

		// Selection was saved while the provider was connected; skip if not
		// anymore so WP AI does not try a disconnected provider first.
		if ( ! self::provider_is_connected( $selection[ $type ][0] ) ) {
			return $models;
		}

		// Avoid duplicate entries if the choice already leads the list.
		$models = array_values(
			array_filter(
				$models,
				static function ( $entry ) use ( $selection, $type ) {
					return ! ( is_array( $entry ) && $entry === $selection[ $type ] );
				}
			)
		);

		array_unshift( $models, $selection[ $type ] );

		return $models;
	}

	/**
	 * Aimogen's curated model lists per connector ID, split by capability.
	 * Sourced from the same constants/fetchers Aimogen's own UI uses, so
	 * the picker is instant - no live provider API round-trips.
	 *
	 * @return array{text: array<int, string>, image: array<int, string>}
	 */
	public static function aimogen_models_for( string $connector_id ): array {
		$text  = array();
		$image = array();

		switch ( $connector_id ) {
			case 'openai':
				$text  = defined( 'AIMOGEN_MODELS_CHAT' ) ? AIMOGEN_MODELS_CHAT : array();
				$image = defined( 'AIMOGEN_DALLE_IMAGE_MODELS' ) ? AIMOGEN_DALLE_IMAGE_MODELS : array();

				// Fine-tuned models created through Aimogen (OpenAI fine-tunes).
				$custom = get_option( 'aiomatic_custom_models', array() );
				if ( is_array( $custom ) && ! empty( $custom ) ) {
					$text = array_merge( array_map( 'strval', array_values( $custom ) ), $text );
				}
				break;
			case 'anthropic':
				$text = defined( 'AIMOGEN_CLAUDE_MODELS' ) ? AIMOGEN_CLAUDE_MODELS : array();
				break;
			case 'google':
				$text  = defined( 'AIMOGEN_GOOGLE_MODELS' ) ? AIMOGEN_GOOGLE_MODELS : array();
				$image = defined( 'AIMOGEN_GOOGLE_IMAGE_MODELS' ) ? AIMOGEN_GOOGLE_IMAGE_MODELS : array();
				break;
			case 'perplexity':
				$text = defined( 'AIMOGEN_PERPLEXITY_MODELS' ) ? AIMOGEN_PERPLEXITY_MODELS : array();
				break;
			case 'groq':
				$text = defined( 'AIMOGEN_GROQ_MODELS' ) ? AIMOGEN_GROQ_MODELS : array();
				break;
			case 'nvidia':
				$text = defined( 'AIMOGEN_NVIDIA_MODELS' ) ? AIMOGEN_NVIDIA_MODELS : array();
				break;
			case 'xai':
				$text = defined( 'AIMOGEN_XAI_MODELS' ) ? AIMOGEN_XAI_MODELS : array();
				break;
			case 'openrouter':
				if ( function_exists( 'aiomatic_get_openrouter_models' ) ) {
					try {
						$openrouter = aiomatic_get_openrouter_models();
						if ( $openrouter !== false && ! empty( $openrouter['source_list'] ) ) {
							foreach ( $openrouter['source_list'] as $smodel ) {
								if ( ! empty( $smodel['model'] ) ) {
									$text[] = (string) $smodel['model'];
								}
							}
							sort( $text );
						}
					} catch ( Throwable $e ) {
						if ( function_exists( 'aiomatic_log_to_file' ) ) {
							aiomatic_log_to_file( 'WP AI Connector: failed to list OpenRouter models: ' . $e->getMessage() );
						}
					}
				}
				break;
			case 'huggingface':
				$hf_models = get_option( 'aiomatic_huggingface_models', array() );
				if ( is_array( $hf_models ) ) {
					$text = array_map( 'strval', array_keys( $hf_models ) );
				}
				break;
			case 'ollama':
				if ( function_exists( 'aiomatic_get_ollama_models' ) ) {
					$settings   = self::settings();
					$ollama_url = isset( $settings['ollama_url'] ) ? trim( (string) $settings['ollama_url'] ) : '';
					if ( $ollama_url !== '' ) {
						// Same cooldown pattern as Aimogen's own model listing,
						// so an unreachable Ollama host can't hang the picker.
						$cooldown_key   = 'aiomatic_ollama_cooldown_' . md5( $ollama_url );
						$cooldown_until = get_transient( $cooldown_key );
						if ( ! $cooldown_until || time() >= (int) $cooldown_until ) {
							$ollama_models = aiomatic_get_ollama_models( false );
							if ( $ollama_models !== false && is_array( $ollama_models ) ) {
								delete_transient( $cooldown_key );
								$text = array_map( 'strval', $ollama_models );
							} else {
								set_transient( $cooldown_key, time() + 300, 300 );
							}
						}
					}
				}
				break;
		}

		return array(
			'text'  => array_values( array_filter( array_unique( $text ) ) ),
			'image' => array_values( array_filter( array_unique( $image ) ) ),
		);
	}

	/**
	 * Builds the model picker data: Aimogen's configured providers with
	 * their curated model lists, flagged with whether WP AI can actually
	 * call them (i.e. an AiClient adapter is registered for the provider).
	 *
	 * @return array{providers: array<int, array{id: string, name: string, configured: bool, usable: bool, models: array<int, array{id: string, name: string, caps: array<int, string>}>}>}
	 */
	private static function available_models( bool $force_refresh = false ): array {
		if ( ! $force_refresh ) {
			$cached = get_transient( self::MODELS_CACHE_KEY );
			if ( is_array( $cached ) && isset( $cached['providers'] ) ) {
				return $cached;
			}
		}

		// Providers WP AI can route to: those with an AiClient adapter.
		$adapters = array();
		if ( class_exists( 'WordPress\\AiClient\\AiClient' ) ) {
			try {
				$adapters = \WordPress\AiClient\AiClient::defaultRegistry()->getRegisteredProviderIds();
			} catch ( Throwable $e ) {
				$adapters = array();
			}
		}

		$providers = array();

		foreach ( self::$providers as $connector_id => $config ) {
			// Match the Connectors page: only providers with an Aimogen
			// credential *and* a working AiClient registration appear as
			// "connected" - do not list models for anything else.
			if ( ! self::provider_is_connected( $connector_id ) ) {
				continue;
			}

			$usable = in_array( $connector_id, $adapters, true );

			$models = array();
			if ( $usable ) {
				$lists = self::aimogen_models_for( $connector_id );
				foreach ( $lists['text'] as $model_id ) {
					$models[] = array(
						'id'   => $model_id,
						'name' => $model_id,
						'caps' => in_array( $model_id, $lists['image'], true )
							? array( 'text_generation', 'image_generation' )
							: array( 'text_generation' ),
					);
				}
				foreach ( $lists['image'] as $model_id ) {
					if ( ! in_array( $model_id, $lists['text'], true ) ) {
						$models[] = array(
							'id'   => $model_id,
							'name' => $model_id,
							'caps' => array( 'image_generation' ),
						);
					}
				}
			}

			$providers[] = array(
				'id'         => $connector_id,
				'name'       => $config['name'],
				'configured' => true,
				'connected'  => true,
				'usable'     => $usable,
				'models'     => $models,
			);
		}

		// Usable providers first, keeping Aimogen's order otherwise.
		usort(
			$providers,
			static function ( $a, $b ) {
				return (int) $b['usable'] <=> (int) $a['usable'];
			}
		);

		$data = array( 'providers' => $providers );

		set_transient( self::MODELS_CACHE_KEY, $data, 15 * MINUTE_IN_SECONDS );

		return $data;
	}

	public static function ajax_get_models(): void {
		check_ajax_referer( 'aiomatic_wp_ai_connectors', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
		}

		$force = ! empty( $_POST['refresh'] );
		$data  = self::available_models( $force );

		$data['selection'] = self::selected_models();

		wp_send_json_success( $data );
	}

	public static function ajax_set_model(): void {
		check_ajax_referer( 'aiomatic_wp_ai_connectors', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
		}

		$type = isset( $_POST['type'] ) ? sanitize_key( wp_unslash( $_POST['type'] ) ) : '';
		if ( ! in_array( $type, array( 'text', 'image' ), true ) ) {
			wp_send_json_error( array( 'message' => 'invalid type' ), 400 );
		}

		$provider = isset( $_POST['provider'] ) ? sanitize_text_field( wp_unslash( $_POST['provider'] ) ) : '';
		$model    = isset( $_POST['model'] ) ? sanitize_text_field( wp_unslash( $_POST['model'] ) ) : '';

		$selection = self::selected_models();

		if ( $provider === '' || $model === '' ) {
			// "Automatic" - fall back to the WordPress default preference list.
			unset( $selection[ $type ] );
		} elseif ( ! self::provider_is_connected( $provider ) ) {
			wp_send_json_error( array( 'message' => 'provider not connected' ), 400 );
		} else {
			$selection[ $type ] = array( $provider, $model );
		}

		update_option( self::OPTION_MODELS, $selection, false );

		wp_send_json_success( array( 'selection' => $selection ) );
	}

	/* ─────────────────────────────────────────────────────────────────────
	 * Connectors page data + WP AI plugin filters.
	 * ──────────────────────────────────────────────────────────────────── */

	public static function mark_connectors_connected( array $data ): array {
		if ( ! self::is_managed() || empty( $data['connectors'] ) || ! is_array( $data['connectors'] ) ) {
			return $data;
		}

		foreach ( self::$providers as $connector_id => $config ) {
			if ( empty( $data['connectors'][ $connector_id ]['authentication'] ) ) {
				continue;
			}

			if ( ! self::provider_has_credentials( $connector_id ) ) {
				$data['connectors'][ $connector_id ]['authentication']['isConnected'] = false;
				continue;
			}

			if ( ! self::provider_is_connected( $connector_id ) ) {
				continue;
			}

			$data['connectors'][ $connector_id ]['authentication']['isConnected'] = true;

			if ( empty( $data['connectors'][ $connector_id ]['authentication']['keySource'] )
				|| 'none' === $data['connectors'][ $connector_id ]['authentication']['keySource'] ) {
				$data['connectors'][ $connector_id ]['authentication']['keySource'] = ! empty( $config['keyless'] ) ? 'none' : 'database';
			}
		}

		return $data;
	}

	public static function declare_credentials_present( $has_credentials ): bool {
		if ( ! self::is_managed() ) {
			return (bool) $has_credentials;
		}

		if ( $has_credentials ) {
			return true;
		}

		foreach ( array_keys( self::$providers ) as $connector_id ) {
			if ( self::provider_has_credentials( $connector_id ) ) {
				return true;
			}
		}

		return false;
	}

	public static function declare_credentials_valid( $valid ) {
		if ( ! self::is_managed() ) {
			return $valid;
		}

		// Skip the WP AI plugin's live test prompt: Aimogen handles its own
		// provider routing and validation.
		return true;
	}

	public static function declare_connector_configured( $configured, array $connector_data = array() ): bool {
		if ( ! self::is_managed() ) {
			return (bool) $configured;
		}

		$hook   = current_filter();
		$prefix = 'wpai_is_';
		$suffix = '_connector_configured';

		if ( ! is_string( $hook )
			|| strncmp( $hook, $prefix, strlen( $prefix ) ) !== 0
			|| substr( $hook, -strlen( $suffix ) ) !== $suffix ) {
			return (bool) $configured;
		}

		$connector_id = substr( $hook, strlen( $prefix ), -strlen( $suffix ) );

		return self::provider_has_credentials( $connector_id ) ? true : (bool) $configured;
	}

	/* ─────────────────────────────────────────────────────────────────────
	 * Banner on the Connectors page.
	 * ──────────────────────────────────────────────────────────────────── */

	public static function maybe_render_banner(): void {
		if ( ! is_admin() || ! current_user_can( 'manage_options' ) || ! self::is_connectors_screen() ) {
			return;
		}

		if ( ! self::is_supported() || self::get_mode() === 'off' ) {
			return;
		}

		$managed  = self::is_managed();
		$others   = self::other_managers();
		$conflict = ! empty( $others );

		// Dismissal is honored while we are not managed - another plugin
		// managing the connectors is a legitimate user choice. When we ARE
		// managed and another plugin overrode us, the banner always shows.
		if ( ! $managed && self::is_banner_dismissed() ) {
			return;
		}

		$other_names = implode( ', ', array_values( $others ) );

		if ( $conflict ) {
			$title = $managed
				/* translators: %s: competing plugin name(s). */
				? sprintf( __( '%s took over your AI connectors.', 'aiomatic-automatic-ai-content-writer' ), $other_names )
				/* translators: %s: competing plugin name(s). */
				: sprintf( __( '%s is managing your AI connectors.', 'aiomatic-automatic-ai-content-writer' ), $other_names );
			$sub = $managed
				? __( 'Aimogen Pro is enabled, but another plugin overrode the connectors. Take back control to route WordPress AI through Aimogen Pro again.', 'aiomatic-automatic-ai-content-writer' )
				: __( 'Aimogen Pro can take over instead - the other plugin is switched to observe mode and its settings stay untouched.', 'aiomatic-automatic-ai-content-writer' );
		} elseif ( $managed ) {
			$title = __( 'Managed by Aimogen Pro.', 'aiomatic-automatic-ai-content-writer' );
			$sub   = __( 'WordPress AI requests use your Aimogen Pro providers. Keys stay in sync between this page and Aimogen Pro.', 'aiomatic-automatic-ai-content-writer' );
		} else {
			$title = __( 'Aimogen Pro can manage your AI connectors.', 'aiomatic-automatic-ai-content-writer' );
			$sub   = __( 'Use your existing Aimogen Pro API keys, models, logs, limits and 10 providers from one place - in one click.', 'aiomatic-automatic-ai-content-writer' );
		}

		$payload = array(
			'nonce'      => wp_create_nonce( 'aiomatic_wp_ai_connectors' ),
			'ajaxUrl'    => admin_url( 'admin-ajax.php' ),
			'settings'   => admin_url( 'admin.php?page=aiomatic_admin_settings#tab-2' ),
			'learnUrl'   => apply_filters( 'aiomatic_wp_ai_connectors_learn_more_url', 'https://getaimogen.com/wordpress-7-0-connectors-documentation-aimogen-pro/' ),
			'managed'    => $managed,
			'conflict'   => $conflict,
			'title'      => $title,
			'sub'        => $sub,
			'enable'     => __( 'Enable', 'aiomatic-automatic-ai-content-writer' ),
			'takeOver'   => __( 'Take over', 'aiomatic-automatic-ai-content-writer' ),
			'takeBack'   => __( 'Take back control', 'aiomatic-automatic-ai-content-writer' ),
			'dismiss'    => __( 'Dismiss', 'aiomatic-automatic-ai-content-writer' ),
			'configure'  => __( 'Configure', 'aiomatic-automatic-ai-content-writer' ),
			'stop'       => __( 'Stop', 'aiomatic-automatic-ai-content-writer' ),
			'learnMore'  => __( 'Learn more', 'aiomatic-automatic-ai-content-writer' ),
			'manageLead' => __( 'Need another provider? Add or edit API keys in', 'aiomatic-automatic-ai-content-writer' ),
			'manageLink' => __( 'Aimogen Pro settings', 'aiomatic-automatic-ai-content-writer' ),
			'modelsLead' => __( 'WordPress AI models', 'aiomatic-automatic-ai-content-writer' ),
			'textModel'  => __( 'Text', 'aiomatic-automatic-ai-content-writer' ),
			'imageModel' => __( 'Image', 'aiomatic-automatic-ai-content-writer' ),
			'automatic'  => __( 'Automatic (WordPress default)', 'aiomatic-automatic-ai-content-writer' ),
			'loading'    => __( 'Loading models…', 'aiomatic-automatic-ai-content-writer' ),
			'saved'      => __( 'Saved', 'aiomatic-automatic-ai-content-writer' ),
			'saveFail'   => __( 'Could not save', 'aiomatic-automatic-ai-content-writer' ),
			'refresh'    => __( 'Refresh model list', 'aiomatic-automatic-ai-content-writer' ),
			'noModels'   => __( 'Connect a provider above to choose models.', 'aiomatic-automatic-ai-content-writer' ),
			'needsAdapter' => __( 'not yet available to WordPress AI — reload this page', 'aiomatic-automatic-ai-content-writer' ),
		);
		?>
		<style>
			.aiomatic-wpai-banner{margin:0 0 16px;padding:12px 16px;display:flex;align-items:center;gap:12px;background:#f0f4ff;border:1px solid #d6deff;border-radius:4px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:13px;line-height:1.4;color:#1e1e1e}
			.aiomatic-wpai-banner.is-managed{background:#e8f8ef;border-color:#c3ecd3}
			.aiomatic-wpai-banner.is-conflict{background:#fff8e6;border-color:#f5d98f}
			.aiomatic-wpai-banner-icon{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;background:#2f5fff;color:#fff}
			.aiomatic-wpai-banner.is-managed .aiomatic-wpai-banner-icon{background:#10b981}
			.aiomatic-wpai-banner.is-conflict .aiomatic-wpai-banner-icon{background:#f0a000}
			.aiomatic-wpai-banner.is-conflict .aiomatic-wpai-primary{background:#f0a000}
			.aiomatic-wpai-banner.is-conflict .aiomatic-wpai-primary:hover{background:#cc8800}
			.aiomatic-wpai-banner-icon svg{width:18px;height:18px;display:block}
			.aiomatic-wpai-banner-text{flex:1;min-width:0}
			.aiomatic-wpai-banner-text strong{font-weight:600}
			.aiomatic-wpai-banner-text span{color:#50575e}
			.aiomatic-wpai-actions{display:flex;gap:6px;flex-shrink:0}
			.aiomatic-wpai-btn{appearance:none;border:1px solid transparent;border-radius:3px;padding:4px 12px;font:inherit;font-size:12px;font-weight:500;cursor:pointer;text-decoration:none;transition:background .12s ease,border-color .12s ease}
			.aiomatic-wpai-primary{background:#2f5fff;color:#fff}
			.aiomatic-wpai-primary:hover{background:#2448cc;color:#fff}
			.aiomatic-wpai-banner.is-managed .aiomatic-wpai-primary{background:#10b981}
			.aiomatic-wpai-banner.is-managed .aiomatic-wpai-primary:hover{background:#0d9b6d}
			.aiomatic-wpai-ghost{background:transparent;color:#50575e}
			.aiomatic-wpai-ghost:hover{background:rgba(0,0,0,.04);color:#1e1e1e}
			.aiomatic-wpai-btn[disabled]{opacity:.5;cursor:default}
			.aiomatic-wpai-banner{flex-wrap:wrap}
			.aiomatic-wpai-models{flex-basis:100%;display:flex;align-items:center;gap:10px;margin-top:10px;padding-top:10px;border-top:1px solid rgba(0,0,0,.07);flex-wrap:wrap}
			.aiomatic-wpai-models-lead{font-weight:600;color:#1e1e1e}
			.aiomatic-wpai-models label{display:flex;align-items:center;gap:5px;color:#50575e;font-size:12px}
			.aiomatic-wpai-models select{font-size:12px;max-width:240px;padding:2px 24px 2px 8px;border:1px solid #c3c4c7;border-radius:3px;background-color:#fff;line-height:1.6;min-height:26px}
			.aiomatic-wpai-models select[disabled]{opacity:.6}
			.aiomatic-wpai-models-status{font-size:12px;color:#10b981;opacity:0;transition:opacity .2s ease}
			.aiomatic-wpai-models-status.is-visible{opacity:1}
			.aiomatic-wpai-models-status.is-error{color:#d63638}
			.aiomatic-wpai-models-refresh{background:transparent;border:none;cursor:pointer;color:#50575e;padding:2px;line-height:0;border-radius:3px}
			.aiomatic-wpai-models-refresh:hover{background:rgba(0,0,0,.06);color:#1e1e1e}
			.aiomatic-wpai-models-refresh svg{width:14px;height:14px}
			.aiomatic-wpai-models-refresh.is-spinning svg{animation:aiomatic-wpai-spin 1s linear infinite}
			@keyframes aiomatic-wpai-spin{to{transform:rotate(360deg)}}
		</style>
		<script>
		(function(){
			var D = <?php echo wp_json_encode( $payload ); ?>;

			function postMode(mode, button) {
				button.disabled = true;
				var f = new FormData();
				f.append('action', 'aiomatic_wp_ai_connectors_set_mode');
				f.append('nonce', D.nonce);
				f.append('mode', mode);
				fetch(D.ajaxUrl, {method:'POST', credentials:'same-origin', body:f})
					.then(function(response){
						if (!response.ok) { throw new Error('request failed'); }
						window.location.reload();
					})
					.catch(function(){ button.disabled = false; });
			}

			function build() {
				var box = document.createElement('div');
				box.className = 'aiomatic-wpai-banner'
					+ (D.conflict ? ' is-conflict' : (D.managed ? ' is-managed' : ''));
				box.setAttribute('role', 'status');

				var iconSvg;
				if (D.conflict) {
					iconSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4"></path><path d="M12 17h.01"></path><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"></path></svg>';
				} else if (D.managed) {
					iconSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="5 12 10 17 19 7"></polyline></svg>';
				} else {
					iconSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"></path><path d="M5 12h14"></path></svg>';
				}

				box.innerHTML =
					'<span class="aiomatic-wpai-banner-icon">' + iconSvg + '</span>' +
					'<div class="aiomatic-wpai-banner-text"><strong></strong> <span></span></div>' +
					'<div class="aiomatic-wpai-actions"></div>';

				box.querySelector('strong').textContent = D.title;
				box.querySelector('.aiomatic-wpai-banner-text span').textContent = D.sub;

				var actions = box.querySelector('.aiomatic-wpai-actions');

				function btn(label, cls, mode, href, newTab) {
					var el = href ? document.createElement('a') : document.createElement('button');
					el.className = 'aiomatic-wpai-btn ' + cls;
					el.textContent = label;
					if (href) {
						el.href = href;
						if (newTab) {
							el.target = '_blank';
							el.rel = 'noopener noreferrer';
						}
					} else {
						el.type = 'button';
						el.addEventListener('click', function(){ postMode(mode, el); });
					}
					actions.appendChild(el);
				}

				if (D.conflict) {
					// Posting 'managed' (again) demotes competing managers
					// server-side and re-syncs our keys to the connectors.
					btn(D.managed ? D.takeBack : D.takeOver, 'aiomatic-wpai-primary', 'managed', null, false);
					btn(D.learnMore, 'aiomatic-wpai-ghost', null, D.learnUrl, true);
					if (D.managed) {
						btn(D.stop, 'aiomatic-wpai-ghost', 'observe', null, false);
					} else {
						btn(D.dismiss, 'aiomatic-wpai-ghost', 'dismiss', null, false);
					}
				} else if (D.managed) {
					btn(D.configure, 'aiomatic-wpai-primary', null, D.settings, false);
					btn(D.stop, 'aiomatic-wpai-ghost', 'observe', null, false);
				} else {
					btn(D.enable, 'aiomatic-wpai-primary', 'managed', null, false);
					btn(D.learnMore, 'aiomatic-wpai-ghost', null, D.learnUrl, true);
					btn(D.dismiss, 'aiomatic-wpai-ghost', 'dismiss', null, false);
				}

				if (D.managed && !D.conflict) {
					box.appendChild(buildModelPicker());
				}

				return box;
			}

			// ── Model picker ─────────────────────────────────────────────
			// Lets the user choose which provider/model WordPress AI
			// features use. Populated from the AiClient registry via AJAX:
			// only providers WP AI can actually call are listed.

			function buildModelPicker() {
				var row = document.createElement('div');
				row.className = 'aiomatic-wpai-models';

				var lead = document.createElement('span');
				lead.className = 'aiomatic-wpai-models-lead';
				lead.textContent = D.modelsLead + ':';
				row.appendChild(lead);

				var selects = {};
				[['text', D.textModel], ['image', D.imageModel]].forEach(function(def) {
					var label = document.createElement('label');
					label.appendChild(document.createTextNode(def[1]));
					var sel = document.createElement('select');
					sel.disabled = true;
					var opt = document.createElement('option');
					opt.textContent = D.loading;
					sel.appendChild(opt);
					sel.addEventListener('change', function(){ saveModel(def[0], sel, status); });
					label.appendChild(sel);
					row.appendChild(label);
					selects[def[0]] = sel;
				});

				var refresh = document.createElement('button');
				refresh.type = 'button';
				refresh.className = 'aiomatic-wpai-models-refresh';
				refresh.title = D.refresh;
				refresh.setAttribute('aria-label', D.refresh);
				refresh.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><polyline points="21 3 21 9 15 9"></polyline></svg>';
				refresh.addEventListener('click', function(){ loadModels(selects, status, refresh, true); });
				row.appendChild(refresh);

				var status = document.createElement('span');
				status.className = 'aiomatic-wpai-models-status';
				status.setAttribute('role', 'status');
				row.appendChild(status);

				loadModels(selects, status, refresh, false);

				return row;
			}

			function flashStatus(status, text, isError) {
				status.textContent = text;
				status.classList.toggle('is-error', !!isError);
				status.classList.add('is-visible');
				clearTimeout(status._t);
				status._t = setTimeout(function(){ status.classList.remove('is-visible'); }, 2500);
			}

			function fillSelect(sel, providers, cap, selection) {
				sel.innerHTML = '';

				var auto = document.createElement('option');
				auto.value = '';
				auto.textContent = D.automatic;
				sel.appendChild(auto);

				var any = false;
				providers.forEach(function(p) {
					if (!p.configured || !p.connected) return;

					var models = (p.models || []).filter(function(m){ return (m.caps || []).indexOf(cap) !== -1; });
					if (!models.length) return;
					any = true;
					var group = document.createElement('optgroup');
					group.label = p.name || p.id;
					models.forEach(function(m) {
						var opt = document.createElement('option');
						opt.value = p.id + '::' + m.id;
						opt.textContent = m.name && m.name !== m.id ? m.name + ' (' + m.id + ')' : m.id;
						group.appendChild(opt);
					});
					sel.appendChild(group);
				});

				if (selection && selection.length === 2) {
					sel.value = selection[0] + '::' + selection[1];
					// Saved model no longer in the catalog: keep it selectable.
					if (sel.value !== selection[0] + '::' + selection[1]) {
						var kept = document.createElement('option');
						kept.value = selection[0] + '::' + selection[1];
						kept.textContent = selection[1] + ' (' + selection[0] + ')';
						sel.appendChild(kept);
						sel.value = kept.value;
					}
				}

				sel.disabled = !any && sel.options.length <= 1;
				return any;
			}

			function loadModels(selects, status, refresh, force) {
				refresh.classList.add('is-spinning');
				var f = new FormData();
				f.append('action', 'aiomatic_wp_ai_connectors_get_models');
				f.append('nonce', D.nonce);
				if (force) f.append('refresh', '1');
				fetch(D.ajaxUrl, {method:'POST', credentials:'same-origin', body:f})
					.then(function(r){ return r.json(); })
					.then(function(json) {
						refresh.classList.remove('is-spinning');
						if (!json || !json.success) { throw new Error('bad response'); }
						var providers = json.data.providers || [];
						var selection = json.data.selection || {};
						var anyText = fillSelect(selects.text, providers, 'text_generation', selection.text);
						var anyImage = fillSelect(selects.image, providers, 'image_generation', selection.image);
						if (!anyText && !anyImage) {
							flashStatus(status, D.noModels, true);
						}
					})
					.catch(function() {
						refresh.classList.remove('is-spinning');
						flashStatus(status, D.saveFail, true);
					});
			}

			function saveModel(type, sel, status) {
				var parts = sel.value ? sel.value.split('::') : ['', ''];
				sel.disabled = true;
				var f = new FormData();
				f.append('action', 'aiomatic_wp_ai_connectors_set_model');
				f.append('nonce', D.nonce);
				f.append('type', type);
				f.append('provider', parts[0] || '');
				f.append('model', parts.slice(1).join('::') || '');
				fetch(D.ajaxUrl, {method:'POST', credentials:'same-origin', body:f})
					.then(function(r){ return r.json(); })
					.then(function(json) {
						sel.disabled = false;
						if (!json || !json.success) { throw new Error('bad response'); }
						flashStatus(status, D.saved, false);
					})
					.catch(function() {
						sel.disabled = false;
						flashStatus(status, D.saveFail, true);
					});
			}

			// Insert as the first child of .connectors-page so it inherits the
			// SPA column width; only (re-)insert when the banner is missing.
			// Never reposition an attached banner: React owns this container,
			// and moving nodes inside it triggers re-renders that would fight
			// us forever. Fall back to placing it after the page header.
			function ensureBanner() {
				if (document.querySelector('.aiomatic-wpai-banner')) {
					return true;
				}

				var page = document.querySelector('.connectors-page');
				if (page) {
					page.insertBefore(build(), page.firstChild);
					return true;
				}

				var header = document.querySelector('.boot-layout__stage header');
				if (header && header.parentNode) {
					header.parentNode.insertBefore(build(), header.nextSibling);
					return true;
				}

				return false;
			}

			// When managed: rewrite the "search the plugin directory" hint to
			// point at Aimogen Pro. Guarded by a dataset flag so each node is
			// only touched once, even if React recreates it.
			function tweakManagedPage() {
				if (!D.managed) return;
				var page = document.querySelector('.connectors-page');
				if (!page) return;

				var ps = page.querySelectorAll('p');
				for (var i = 0; i < ps.length; i++) {
					var p = ps[i];
					if (p.dataset.aiomaticReplaced) continue;
					var t = (p.textContent || '').toLowerCase();
					if (t.indexOf('plugin directory') !== -1 || t.indexOf('search') !== -1) {
						p.innerHTML = '';
						var a = document.createElement('a');
						a.href = D.settings;
						a.textContent = D.manageLink;
						p.appendChild(document.createTextNode(D.manageLead + ' '));
						p.appendChild(a);
						p.appendChild(document.createTextNode('.'));
						p.dataset.aiomaticReplaced = '1';
					}
				}
			}

			// When managed, redirect "Edit" (connected) and "Set up" (no key
			// yet) clicks to Aimogen Pro's API settings - keys live in
			// Aimogen, and editing them via the core dialog would let WP
			// core's REST validation wipe keys for providers it has no
			// AiClient adapter for. One delegated capture listener; no
			// per-button wiring, so React re-renders never need
			// re-processing.
			if (D.managed) {
				document.addEventListener('click', function(e){
					var b = e.target && e.target.closest ? e.target.closest('.connectors-page button') : null;
					if (!b) return;
					var label = (b.textContent || '').trim();
					if (label === 'Edit' || label === 'Set up') {
						e.preventDefault();
						e.stopPropagation();
						window.location.href = D.settings;
					}
				}, true);
			}

			function run() {
				var inserted = ensureBanner();
				tweakManagedPage();
				return inserted;
			}

			// Fast bootstrap while the SPA mounts, then a slow keep-alive that
			// re-inserts the banner only if a React re-render removed it.
			// Deliberately no MutationObserver: observing the React-rendered
			// subtree while also mutating it creates a feedback loop that
			// burns CPU for as long as the page is open.
			var tries = 0;
			var bootstrap = setInterval(function(){
				if (run() || ++tries > 40) {
					clearInterval(bootstrap);
					setInterval(function(){
						if (!document.hidden) run();
					}, 2000);
				}
			}, 120);
		})();
		</script>
		<?php
	}

	public static function ajax_set_mode(): void {
		check_ajax_referer( 'aiomatic_wp_ai_connectors', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
		}

		$mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : 'observe';

		// Hide the banner without changing the integration mode.
		if ( $mode === 'dismiss' ) {
			update_option( self::OPTION_DISMISSED, '1', false );
			wp_send_json_success( array( 'dismissed' => true ) );
		}

		if ( ! in_array( $mode, array( 'observe', 'managed', 'off' ), true ) ) {
			wp_send_json_error( array( 'message' => 'invalid mode' ), 400 );
		}

		update_option( self::OPTION_MODE, $mode, false );
		delete_option( self::OPTION_DISMISSED );

		if ( $mode === 'managed' ) {
			// Taking over: stop competing manager plugins (their keys and
			// settings stay untouched) and push our keys to the connectors.
			self::demote_other_managers();
			self::mirror_settings_to_connectors( array(), self::settings() );
			self::approve_known_callers();
		}

		wp_send_json_success( array( 'mode' => $mode ) );
	}

	/* ─────────────────────────────────────────────────────────────────────
	 * Helpers.
	 * ──────────────────────────────────────────────────────────────────── */

	private static function provider_has_credentials( string $connector_id ): bool {
		return self::aimogen_credential( $connector_id ) !== '';
	}

	/**
	 * Whether a provider should show as "connected" on the Connectors page
	 * and appear in the model picker: Aimogen holds a credential and the
	 * AiClient registry reports the provider as configured.
	 */
	private static function provider_is_connected( string $connector_id ): bool {
		if ( ! self::provider_has_credentials( $connector_id ) ) {
			return false;
		}

		if ( ! class_exists( 'WordPress\\AiClient\\AiClient' ) ) {
			return false;
		}

		try {
			$registry = \WordPress\AiClient\AiClient::defaultRegistry();

			return $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
		} catch ( Throwable $e ) {
			return false;
		}
	}

	private static function connector_option_name( string $connector_id ): string {
		return 'connectors_ai_' . str_replace( '-', '_', $connector_id ) . '_api_key';
	}

	/** Reverse of connector_option_name(); null if not a managed provider option. */
	private static function connector_id_from_option_name( string $option ) {
		if ( strncmp( $option, 'connectors_ai_', 14 ) !== 0 || substr( $option, -8 ) !== '_api_key' ) {
			return null;
		}

		$id = substr( $option, 14, -8 );

		foreach ( array_keys( self::$providers ) as $connector_id ) {
			if ( str_replace( '-', '_', $connector_id ) === $id ) {
				return $connector_id;
			}
		}

		return null;
	}

	/** First non-empty line of a (possibly multi-line) key setting. */
	private static function first_line( string $value ): string {
		foreach ( preg_split( '/\r\n|\r|\n/', $value ) as $line ) {
			$line = trim( $line );
			if ( $line !== '' ) {
				return $line;
			}
		}

		return '';
	}

	/** Replaces the first key line, keeping any extra round-robin key lines. */
	private static function replace_first_line( string $value, string $new_first ): string {
		$lines = preg_split( '/\r\n|\r|\n/', $value );
		$rest  = array();
		$found = false;

		foreach ( $lines as $line ) {
			if ( ! $found && trim( $line ) !== '' ) {
				$found = true;
				continue;
			}
			if ( trim( $line ) !== '' ) {
				$rest[] = trim( $line );
			}
		}

		array_unshift( $rest, $new_first );

		return implode( "\n", array_filter( $rest, static function( $line ){ return $line !== ''; } ) );
	}

	private static function settings(): array {
		$settings = get_option( 'aiomatic_Main_Settings', array() );
		return is_array( $settings ) ? $settings : array();
	}

	private static function get_mode(): string {
		$mode = get_option( self::OPTION_MODE, 'observe' );
		return in_array( $mode, array( 'observe', 'managed', 'off' ), true ) ? $mode : 'observe';
	}

	private static function is_managed(): bool {
		return self::get_mode() === 'managed' && self::is_supported();
	}

	private static function is_supported(): bool {
		return class_exists( 'WP_Connector_Registry' )
			&& ( ! function_exists( 'wp_supports_ai' ) || wp_supports_ai() );
	}

	private static function is_banner_dismissed(): bool {
		return (bool) get_option( self::OPTION_DISMISSED, false );
	}

	private static function is_connectors_screen(): bool {
		global $pagenow;

		if ( $pagenow === 'options-connectors.php' ) {
			return true;
		}

		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;

		return $screen && ! empty( $screen->id ) && strpos( (string) $screen->id, 'options-connectors' ) !== false;
	}

	private static function plugin_file(): string {
		if ( defined( 'AIOMATIC_PLUGIN_FILE' ) ) {
			return plugin_basename( AIOMATIC_PLUGIN_FILE );
		}

		if ( defined( 'AIOMATIC_PATH' ) ) {
			return plugin_basename( trailingslashit( AIOMATIC_PATH ) . 'aiomatic-automatic-ai-content-writer.php' );
		}

		return 'aiomatic-automatic-ai-content-writer/aiomatic-automatic-ai-content-writer.php';
	}
}

if ( interface_exists( 'WordPress\\AiClientDependencies\\Psr\\SimpleCache\\CacheInterface' ) ) {
	/**
	 * Persistent PSR-16 cache for the WP AI Client, backed by transients.
	 *
	 * WP core's own adapter (`WP_AI_Client_Cache`) uses `wp_cache_*`, which is
	 * request-scoped unless a persistent object cache drop-in is installed.
	 * Without one, the AI Client re-fetches provider model catalogs over HTTP
	 * on every request - and a single slow or failed fetch surfaces as an
	 * `unsupported_model` error in the editor. Transients survive across
	 * requests on every WordPress install (and automatically use the object
	 * cache when a drop-in is present), making generation reliable.
	 */
	class Aiomatic_WP_AI_Client_Transient_Cache implements \WordPress\AiClientDependencies\Psr\SimpleCache\CacheInterface {

		private const PREFIX      = 'aiom_wpai_';
		private const DEFAULT_TTL = 12 * HOUR_IN_SECONDS;

		private function key( string $key ): string {
			// Transient names are length-limited; PSR keys are not.
			return self::PREFIX . md5( $key );
		}

		private function ttl_to_seconds( $ttl ): int {
			if ( null === $ttl ) {
				return self::DEFAULT_TTL;
			}

			if ( $ttl instanceof DateInterval ) {
				$now = new DateTime();
				$end = ( clone $now )->add( $ttl );
				return max( 0, $end->getTimestamp() - $now->getTimestamp() );
			}

			return max( 0, (int) $ttl );
		}

		public function get( $key, $default_value = null ) {
			$value = get_transient( $this->key( (string) $key ) );

			// Transients cannot distinguish a stored `false` from a miss;
			// the AI Client only caches arrays/objects, so treat as a miss.
			return false === $value ? $default_value : $value;
		}

		public function set( $key, $value, $ttl = null ): bool {
			return set_transient( $this->key( (string) $key ), $value, $this->ttl_to_seconds( $ttl ) );
		}

		public function delete( $key ): bool {
			return delete_transient( $this->key( (string) $key ) );
		}

		public function clear(): bool {
			// No key index is kept; entries expire via their TTL.
			return false;
		}

		public function getMultiple( $keys, $default_value = null ): array {
			$result = array();
			foreach ( $keys as $key ) {
				$result[ $key ] = $this->get( $key, $default_value );
			}
			return $result;
		}

		public function setMultiple( $values, $ttl = null ): bool {
			$ok = true;
			foreach ( $values as $key => $value ) {
				$ok = $this->set( $key, $value, $ttl ) && $ok;
			}
			return $ok;
		}

		public function deleteMultiple( $keys ): bool {
			$ok = true;
			foreach ( $keys as $key ) {
				$ok = $this->delete( $key ) && $ok;
			}
			return $ok;
		}

		public function has( $key ): bool {
			return false !== get_transient( $this->key( (string) $key ) );
		}
	}
}
