<?php
/**
 * Antimanual AI settings bridge.
 *
 * Provides a single place for AM Builder to read provider settings from the
 * parent Antimanual plugin.
 *
 * @package Antimanual_Builder
 * @since   1.1.0
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class AMBLD_Antimanual_Ai_Provider
 */
class AMBLD_Antimanual_Ai_Provider {

	/**
	 * Default OpenAI model.
	 *
	 * @var string
	 */
	const DEFAULT_OPENAI_MODEL = 'gpt-5-mini';

	/**
	 * Default Gemini model.
	 *
	 * @var string
	 */
	const DEFAULT_GEMINI_MODEL = 'gemini-3-flash-preview';

	/**
	 * Provider registry — mirrors the parent plugin's `AIProvider::PROVIDERS`.
	 *
	 * Each entry holds the option keys used by the parent plugin (so we read the
	 * correct slot in `wp_options`) plus a base URL for OpenAI-compatible
	 * providers and capability flags. Keep in sync with the parent plugin's
	 * registry — adding a provider here without configuring keys upstream will
	 * just return an empty key.
	 *
	 * @var array<string, array{label:string,key_option:string,model_option:string,base_url:string,openai_compatible:bool,default_model:string}>
	 */
	const PROVIDERS = array(
		'openai'      => array(
			'label'             => 'OpenAI',
			'key_option'        => 'openai_api_key',
			'model_option'      => 'openai_response_model',
			'base_url'          => 'https://api.openai.com/v1',
			'openai_compatible' => true,
			'default_model'     => self::DEFAULT_OPENAI_MODEL,
		),
		'gemini'      => array(
			'label'             => 'Gemini',
			'key_option'        => 'gemini_api_key',
			'model_option'      => 'gemini_response_model',
			'base_url'          => 'https://generativelanguage.googleapis.com/v1beta',
			'openai_compatible' => false,
			'default_model'     => self::DEFAULT_GEMINI_MODEL,
		),
		'xai'         => array(
			'label'             => 'xAI Grok',
			'key_option'        => 'xai_api_key',
			'model_option'      => 'xai_response_model',
			'base_url'          => 'https://api.x.ai/v1',
			'openai_compatible' => true,
			'default_model'     => 'grok-4-fast',
		),
		'deepseek'    => array(
			'label'             => 'DeepSeek',
			'key_option'        => 'deepseek_api_key',
			'model_option'      => 'deepseek_response_model',
			'base_url'          => 'https://api.deepseek.com',
			'openai_compatible' => true,
			'default_model'     => 'deepseek-chat',
		),
		'opencodezen' => array(
			'label'             => 'OpenCode Zen',
			'key_option'        => 'opencodezen_api_key',
			'model_option'      => 'opencodezen_response_model',
			'base_url'          => 'https://opencode.ai/zen/v1',
			'openai_compatible' => true,
			'default_model'     => 'qwen3.6-plus',
		),
		'anthropic'   => array(
			'label'             => 'Anthropic',
			'key_option'        => 'anthropic_api_key',
			'model_option'      => 'anthropic_response_model',
			'base_url'          => 'https://api.anthropic.com/v1',
			'openai_compatible' => false,
			'default_model'     => 'claude-sonnet-4-20250514',
		),
	);

	/**
	 * Check whether Antimanual AI settings are available.
	 *
	 * @return bool
	 */
	public static function is_available() {
		return AMBLD_Antimanual_Knowledge_Base::is_active() && function_exists( 'atml_option' );
	}

	/**
	 * Get the active AI provider.
	 *
	 * Falls back to OpenAI when the parent plugin is unavailable or returns a
	 * provider slug that we don't know about.
	 *
	 * @return string Provider slug from {@see PROVIDERS}.
	 */
	public static function get_provider() {
		if ( ! self::is_available() ) {
			return 'openai';
		}

		$provider = sanitize_key( (string) atml_option( 'last_active_provider', 'openai' ) );

		return self::is_supported( $provider ) ? $provider : 'openai';
	}

	/**
	 * Whether the given provider slug is one we recognise.
	 *
	 * @param string $provider Provider slug.
	 * @return bool
	 */
	public static function is_supported( $provider ) {
		return is_string( $provider ) && isset( self::PROVIDERS[ $provider ] );
	}

	/**
	 * Whether the given provider speaks the OpenAI Chat Completions wire
	 * format. OpenAI-compatible providers can reuse the same HTTP client with
	 * a different base URL.
	 *
	 * @param string $provider Provider slug.
	 * @return bool
	 */
	public static function is_openai_compatible( $provider ) {
		$provider = self::normalize_provider( $provider );
		return ! empty( self::PROVIDERS[ $provider ]['openai_compatible'] );
	}

	/**
	 * Get the API base URL for the given provider.
	 *
	 * @param string $provider Provider slug. Defaults to the active provider.
	 * @return string Base URL with no trailing slash.
	 */
	public static function get_base_url( $provider = '' ) {
		$provider = self::normalize_provider( $provider ? $provider : self::get_provider() );
		return isset( self::PROVIDERS[ $provider ]['base_url'] )
			? self::PROVIDERS[ $provider ]['base_url']
			: self::PROVIDERS['openai']['base_url'];
	}

	/**
	 * Get the configured model for a provider.
	 *
	 * Reads the parent plugin's option storage; for OpenAI/Gemini the parent
	 * also exposes helper functions (`atml_get_*_configs`) which we prefer
	 * because they apply the parent's own normalisation.
	 *
	 * @param string $provider Provider name.
	 * @return string
	 */
	public static function get_model( $provider = '' ) {
		$provider = self::normalize_provider( $provider ? $provider : self::get_provider() );
		$config   = self::PROVIDERS[ $provider ];

		if ( 'openai' === $provider && self::is_available() && function_exists( 'atml_get_openai_configs' ) ) {
			$configs = atml_get_openai_configs();
			if ( ! empty( $configs['response_model'] ) && is_string( $configs['response_model'] ) ) {
				return sanitize_text_field( $configs['response_model'] );
			}
		}

		if ( 'gemini' === $provider && self::is_available() && function_exists( 'atml_get_gemini_configs' ) ) {
			$configs = atml_get_gemini_configs();
			if ( ! empty( $configs['response_model'] ) && is_string( $configs['response_model'] ) ) {
				return sanitize_text_field( $configs['response_model'] );
			}
		}

		if ( 'xai' === $provider && self::is_available() && function_exists( 'atml_get_xai_configs' ) ) {
			$configs = atml_get_xai_configs();
			if ( ! empty( $configs['response_model'] ) && is_string( $configs['response_model'] ) ) {
				return sanitize_text_field( $configs['response_model'] );
			}
		}

		if ( 'deepseek' === $provider && self::is_available() && function_exists( 'atml_get_deepseek_configs' ) ) {
			$configs = atml_get_deepseek_configs();
			if ( ! empty( $configs['response_model'] ) && is_string( $configs['response_model'] ) ) {
				return sanitize_text_field( $configs['response_model'] );
			}
		}

		if ( 'opencodezen' === $provider && self::is_available() && function_exists( 'atml_get_opencodezen_configs' ) ) {
			$configs = atml_get_opencodezen_configs();
			if ( ! empty( $configs['response_model'] ) && is_string( $configs['response_model'] ) ) {
				return sanitize_text_field( $configs['response_model'] );
			}
		}

		if ( 'anthropic' === $provider && self::is_available() && function_exists( 'atml_get_anthropic_configs' ) ) {
			$configs = atml_get_anthropic_configs();
			if ( ! empty( $configs['response_model'] ) && is_string( $configs['response_model'] ) ) {
				return sanitize_text_field( $configs['response_model'] );
			}
		}

		if ( self::is_available() ) {
			$saved_model = atml_option( $config['model_option'], $config['default_model'] );
			if ( ! empty( $saved_model ) && is_string( $saved_model ) ) {
				return sanitize_text_field( $saved_model );
			}
		}

		// Final fallback for OpenAI uses the parent's helper if available so
		// the default tracks upstream changes.
		if ( 'openai' === $provider && function_exists( 'atml_get_default_openai_response_model' ) ) {
			return sanitize_text_field( (string) atml_get_default_openai_response_model() );
		}

		return $config['default_model'];
	}

	/**
	 * Get the API key for a provider.
	 *
	 * @param string $provider Provider name.
	 * @return string
	 */
	public static function get_api_key( $provider = '' ) {
		if ( ! self::is_available() ) {
			return '';
		}

		$provider = self::normalize_provider( $provider ? $provider : self::get_provider() );
		$option   = self::PROVIDERS[ $provider ]['key_option'];
		$api_key  = atml_option( $option, '' );

		return is_string( $api_key ) ? trim( $api_key ) : '';
	}

	/**
	 * Check whether a provider has an API key.
	 *
	 * @param string $provider Provider name.
	 * @return bool
	 */
	public static function has_api_key( $provider = '' ) {
		return '' !== self::get_api_key( $provider );
	}

	/**
	 * Get the human-readable label for a provider.
	 *
	 * @param string $provider Provider slug. Defaults to active.
	 * @return string
	 */
	public static function get_provider_label( $provider = '' ) {
		$provider = self::normalize_provider( $provider ? $provider : self::get_provider() );
		return isset( self::PROVIDERS[ $provider ]['label'] )
			? self::PROVIDERS[ $provider ]['label']
			: ucfirst( $provider );
	}

	/**
	 * Get runtime settings keyed for the active provider.
	 *
	 * The returned array is what the chat / generation pipelines pass around.
	 * It carries:
	 *   - `provider`            — active provider slug
	 *   - `model`               — active provider's configured model
	 *   - `base_url`            — base URL for the active provider
	 *   - `<slug>_api_key` / `<slug>_model` — per-provider entries for
	 *      every registered provider (existing call sites still read
	 *      `openai_model` / `gemini_model` directly).
	 *
	 * @return array
	 */
	public static function get_runtime_settings() {
		$provider = self::get_provider();
		$out      = array(
			'provider' => $provider,
			'model'    => self::get_model( $provider ),
			'base_url' => self::get_base_url( $provider ),
		);

		foreach ( self::PROVIDERS as $slug => $config ) {
			$out[ $slug . '_api_key' ] = self::get_api_key( $slug );
			$out[ $slug . '_model' ]   = self::get_model( $slug );
			$out[ $slug . '_base_url' ] = self::get_base_url( $slug );
		}

		return $out;
	}

	/**
	 * Build a safe payload for React admin/editor surfaces.
	 *
	 * @return array
	 */
	public static function get_settings_payload() {
		$active    = self::get_provider();
		$providers = array();

		foreach ( self::PROVIDERS as $slug => $config ) {
			$key                = self::get_api_key( $slug );
			$chat_models        = self::is_available() && function_exists( 'atml_get_supported_provider_models' )
				? atml_get_supported_provider_models( $slug, 'chat' )
				: array();
			$providers[ $slug ] = array(
				'slug'             => $slug,
				'label'            => $config['label'],
				'model'            => self::get_model( $slug ),
				'models'           => $chat_models,
				// Per-model metadata (output ceiling + reasoning capability)
				// read straight from the parent catalog. Drives the model-aware
				// Max Output cap and the effort selector in the Settings panel.
				'modelMeta'        => self::get_model_meta_map( $slug ),
				'hasKey'           => '' !== $key,
				'partial'          => self::mask_api_key( $key ),
				'openaiCompatible' => ! empty( $config['openai_compatible'] ),
			);
		}

		return array(
			'installed' => AMBLD_Antimanual_Knowledge_Base::is_installed(),
			'active'    => AMBLD_Antimanual_Knowledge_Base::is_active(),
			'available' => self::is_available(),
			'provider'  => $active,
			'model'     => self::get_model( $active ),
			'providers' => $providers,
			'hasApiKey' => self::has_api_key(),
			'adminUrl'  => esc_url_raw( admin_url( 'admin.php?page=antimanual' ) ),
			// Destinations for the dependency notice when Antimanual is missing:
			// one-click activate (installed), the plugins list (fallback), or the
			// directory install search (not present).
			'activateUrl' => esc_url_raw( AMBLD_Antimanual_Knowledge_Base::get_activate_url() ),
			'pluginsUrl'  => esc_url_raw( admin_url( 'plugins.php' ) ),
			'installUrl'  => esc_url_raw( admin_url( 'plugin-install.php?s=Antimanual&tab=search&type=term' ) ),
			'message'     => self::get_status_message(),
			// ── Backwards-compat scalars (kept so old admin surfaces don't break)
			'openaiModel'   => self::get_model( 'openai' ),
			'geminiModel'   => self::get_model( 'gemini' ),
			'openaiKey'     => '' !== self::get_api_key( 'openai' ),
			'geminiKey'     => '' !== self::get_api_key( 'gemini' ),
			'openaiPartial' => self::mask_api_key( self::get_api_key( 'openai' ) ),
			'geminiPartial' => self::mask_api_key( self::get_api_key( 'gemini' ) ),
		);
	}

	/**
	 * Get the current dependency/configuration status message.
	 *
	 * @return string
	 */
	public static function get_status_message() {
		if ( ! AMBLD_Antimanual_Knowledge_Base::is_installed() ) {
			return __( 'Install and activate Antimanual to configure AI for AM Builder.', 'antimanual-builder' );
		}

		if ( ! AMBLD_Antimanual_Knowledge_Base::is_active() ) {
			return __( 'Activate Antimanual to configure AI for AM Builder.', 'antimanual-builder' );
		}

		if ( ! self::has_api_key() ) {
			return __( 'Configure an AI provider in Antimanual to use AM Builder AI features.', 'antimanual-builder' );
		}

		return __( 'AI provider settings are managed in the Antimanual plugin.', 'antimanual-builder' );
	}

	/**
	 * Normalize a reasoning_effort value to one a provider accepts.
	 *
	 * The accepted set mirrors the effort levels the parent plugin advertises
	 * per model (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`). Unknown
	 * values fall back to `low`.
	 *
	 * @param string $effort Raw effort value.
	 * @return string One of 'none'|'minimal'|'low'|'medium'|'high'|'xhigh'|'max'.
	 */
	public static function normalize_reasoning_effort( $effort ) {
		$effort = strtolower( trim( (string) $effort ) );
		$valid  = array( 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max' );
		return in_array( $effort, $valid, true ) ? $effort : 'low';
	}

	/**
	 * Per-provider model-metadata cache (keyed by provider slug).
	 *
	 * @var array<string, array>
	 */
	private static $model_meta_cache = array();

	/**
	 * Read per-model metadata for a provider from the parent plugin's store.
	 *
	 * The parent exposes a rich catalog
	 * (`$GLOBALS['ATML_STORE']['<slug>_chat_models']`) carrying
	 * `max_output_tokens` and `reasoning_effort` per model. The
	 * {@see atml_get_supported_provider_models()} helper strips that down to
	 * bare IDs, so we read the raw store entries ourselves and parse the two
	 * fields this plugin needs: the model's true output ceiling and how (if at
	 * all) it exposes reasoning.
	 *
	 * @param string $provider Provider slug.
	 * @return array<string, array{maxOutput:int,reasoning:array{type:string,levels:array<string>}}>
	 */
	public static function get_model_meta_map( $provider ) {
		$provider = self::normalize_provider( $provider );
		if ( isset( self::$model_meta_cache[ $provider ] ) ) {
			return self::$model_meta_cache[ $provider ];
		}

		$store  = isset( $GLOBALS['ATML_STORE'] ) && is_array( $GLOBALS['ATML_STORE'] ) ? $GLOBALS['ATML_STORE'] : array();
		$models = isset( $store[ $provider . '_chat_models' ] ) && is_array( $store[ $provider . '_chat_models' ] )
			? $store[ $provider . '_chat_models' ]
			: array();

		$map = array();
		foreach ( $models as $model ) {
			if ( ! is_array( $model ) ) {
				continue;
			}
			$id = preg_replace( '/[^a-zA-Z0-9._:-]/', '', (string) ( $model['value'] ?? '' ) );
			if ( '' === $id ) {
				continue;
			}
			$map[ $id ] = array(
				'maxOutput' => self::parse_meta_int( $model['max_output_tokens'] ?? '' ),
				'reasoning' => self::parse_model_reasoning( $model['reasoning_effort'] ?? '' ),
			);
		}

		self::$model_meta_cache[ $provider ] = $map;
		return $map;
	}

	/**
	 * Parse a comma-grouped token count from the parent store (e.g. "128,000")
	 * into a plain integer.
	 *
	 * @param mixed $raw Raw value.
	 * @return int Token count, or 0 when not parseable.
	 */
	private static function parse_meta_int( $raw ) {
		$digits = preg_replace( '/[^0-9]/', '', (string) $raw );
		return '' === $digits ? 0 : (int) $digits;
	}

	/**
	 * Parse the parent store's free-form `reasoning_effort` field into a
	 * structured capability descriptor.
	 *
	 * The field has three shapes upstream:
	 *   - a comma list of named levels  → OpenAI-style effort ('effort')
	 *   - "Thinking supported"          → Gemini-style token budget ('token_budget')
	 *   - "Not a reasoning model" / ""  → no reasoning control ('none')
	 *
	 * @param mixed $raw Raw value.
	 * @return array{type:string,levels:array<string>}
	 */
	private static function parse_model_reasoning( $raw ) {
		$raw = strtolower( trim( (string) $raw ) );

		if ( '' === $raw || false !== strpos( $raw, 'not a reasoning' ) ) {
			return array(
				'type'   => 'none',
				'levels' => array(),
			);
		}

		// Gemini-style: the model thinks via a token budget rather than a named
		// effort. We surface a friendly effort scale and translate it to a
		// token budget at call time (see {@see effort_to_thinking_budget()}).
		if ( false !== strpos( $raw, 'thinking supported' ) ) {
			return array(
				'type'   => 'token_budget',
				'levels' => array( 'none', 'low', 'medium', 'high' ),
			);
		}

		$valid  = array( 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max' );
		$levels = array();
		foreach ( explode( ',', $raw ) as $level ) {
			$level = trim( $level );
			if ( in_array( $level, $valid, true ) && ! in_array( $level, $levels, true ) ) {
				$levels[] = $level;
			}
		}

		if ( empty( $levels ) ) {
			return array(
				'type'   => 'none',
				'levels' => array(),
			);
		}

		return array(
			'type'   => 'effort',
			'levels' => $levels,
		);
	}

	/**
	 * Get a model's true max-output-token ceiling from the parent metadata.
	 *
	 * @param string $provider Provider slug.
	 * @param string $model    Model ID.
	 * @return int Token ceiling, or 0 when unknown (metadata missing).
	 */
	public static function get_model_max_output( $provider, $model ) {
		$map = self::get_model_meta_map( $provider );
		$id  = preg_replace( '/[^a-zA-Z0-9._:-]/', '', (string) $model );
		return isset( $map[ $id ]['maxOutput'] ) ? (int) $map[ $id ]['maxOutput'] : 0;
	}

	/**
	 * Get a model's reasoning capability descriptor.
	 *
	 * @param string $provider Provider slug.
	 * @param string $model    Model ID.
	 * @return array{type:string,levels:array<string>}
	 */
	public static function get_model_reasoning( $provider, $model ) {
		$provider  = self::normalize_provider( $provider );
		$map       = self::get_model_meta_map( $provider );
		$id        = preg_replace( '/[^a-zA-Z0-9._:-]/', '', (string) $model );
		$reasoning = isset( $map[ $id ]['reasoning'] )
			? $map[ $id ]['reasoning']
			: array(
				'type'   => 'none',
				'levels' => array(),
			);

		// The parent catalog doesn't yet advertise reasoning for every
		// provider that supports it (e.g. Anthropic). Bridge those here until
		// it does, without re-introducing a full local model table.
		if ( 'none' === $reasoning['type'] ) {
			$fallback = self::fallback_reasoning( $provider, $id );
			if ( 'none' !== $fallback['type'] ) {
				return $fallback;
			}
		}

		return $reasoning;
	}

	/**
	 * Builder-side reasoning fallback for providers the parent catalog doesn't
	 * yet annotate with `reasoning_effort`. Keyed by provider; intentionally
	 * narrow so it can be deleted once the parent supplies the metadata.
	 *
	 * @param string $provider Normalized provider slug.
	 * @param string $model    Sanitized model ID.
	 * @return array{type:string,levels:array<string>}
	 */
	private static function fallback_reasoning( $provider, $model ) {
		if ( 'anthropic' === $provider ) {
			// Claude 4.x models reason via the `effort` control. Haiku tops out
			// at `high`; Opus/Sonnet add `xhigh`/`max`.
			$levels = false !== strpos( strtolower( $model ), 'haiku' )
				? array( 'low', 'medium', 'high' )
				: array( 'low', 'medium', 'high', 'xhigh', 'max' );
			return array(
				'type'   => 'effort',
				'levels' => $levels,
			);
		}

		return array(
			'type'   => 'none',
			'levels' => array(),
		);
	}

	/**
	 * Resolve the effort level to actually use for a model.
	 *
	 * Returns '' when the model exposes no reasoning control (so call sites
	 * omit the field). Otherwise clamps the stored preference to a level the
	 * model actually offers, falling back to a sensible middle ground.
	 *
	 * @param string $provider      Provider slug.
	 * @param string $model         Model ID.
	 * @param string $stored_effort Saved effort preference.
	 * @return string Effort level, or '' for non-reasoning models.
	 */
	public static function resolve_effort_for_model( $provider, $model, $stored_effort ) {
		$reasoning = self::get_model_reasoning( $provider, $model );
		if ( 'none' === $reasoning['type'] || empty( $reasoning['levels'] ) ) {
			return '';
		}

		$effort = self::normalize_reasoning_effort( $stored_effort );
		if ( in_array( $effort, $reasoning['levels'], true ) ) {
			return $effort;
		}

		foreach ( array( 'medium', 'low', 'high' ) as $preferred ) {
			if ( in_array( $preferred, $reasoning['levels'], true ) ) {
				return $preferred;
			}
		}

		return $reasoning['levels'][0];
	}

	/**
	 * Translate an effort level into a Gemini `thinkingBudget` token count.
	 *
	 * The ceiling (24,576) is the safe maximum across current Gemini flash
	 * variants. `none` disables extended thinking entirely.
	 *
	 * @param string $effort Effort level.
	 * @return int Thinking-budget token count (0 disables thinking).
	 */
	public static function effort_to_thinking_budget( $effort ) {
		switch ( self::normalize_reasoning_effort( $effort ) ) {
			case 'none':
				return 0;
			case 'minimal':
			case 'low':
				return 2048;
			case 'high':
				return 16384;
			case 'xhigh':
			case 'max':
				return 24576;
			case 'medium':
			default:
				return 8192;
		}
	}

	/**
	 * Whether the given model is an OpenAI reasoning-style model (o1, o3, o4
	 * series, gpt-5+). These models reject several legacy chat-completion
	 * parameters: `max_tokens` (use `max_completion_tokens`), custom
	 * `temperature`, `top_p`, `presence_penalty`, `frequency_penalty`.
	 *
	 * @param string $model Model name.
	 * @return bool
	 */
	public static function is_reasoning_model( $model ) {
		$model = strtolower( trim( (string) $model ) );
		// o1, o1-mini, o3, o3-mini, o4-mini, o4-pro, etc.
		if ( preg_match( '/^o\d/', $model ) ) {
			return true;
		}
		// gpt-5, gpt-5-mini, gpt-5-turbo, and any future major version ≥ 5.
		if ( preg_match( '/^gpt-([5-9]|\d{2,})/', $model ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Whether a model requires `max_completion_tokens` instead of `max_tokens`.
	 * Currently equivalent to {@see is_reasoning_model()}.
	 *
	 * @param string $model Model name.
	 * @return bool
	 */
	public static function uses_max_completion_tokens( $model ) {
		return self::is_reasoning_model( $model );
	}

	/**
	 * Whether a model accepts a custom `temperature` value. Reasoning models
	 * only accept the API default (1) and reject any explicit value.
	 *
	 * @param string $model Model name.
	 * @return bool
	 */
	public static function supports_custom_temperature( $model ) {
		return ! self::is_reasoning_model( $model );
	}

	/**
	 * Normalize a provider slug. Unknown slugs fall back to OpenAI so
	 * downstream code never has to handle a missing registry entry.
	 *
	 * @param string $provider Provider name.
	 * @return string
	 */
	private static function normalize_provider( $provider ) {
		$slug = sanitize_key( (string) $provider );
		return self::is_supported( $slug ) ? $slug : 'openai';
	}

	/**
	 * Mask an API key for safe display.
	 *
	 * @param string $api_key API key.
	 * @return string
	 */
	private static function mask_api_key( $api_key ) {
		$api_key = is_string( $api_key ) ? trim( $api_key ) : '';

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

		if ( strlen( $api_key ) <= 4 ) {
			return str_repeat( '•', strlen( $api_key ) );
		}

		return str_repeat( '•', strlen( $api_key ) - 4 ) . substr( $api_key, -4 );
	}
}
