<?php
/**
 * Model Config Registry - Centralized Store for AI Model Configurations
 *
 * This file implements a singleton registry that stores configuration arrays for
 * every AI model the plugin can use. Each model must have a registered config
 * to be usable; the Provider Manager gates on config existence, preventing
 * unconfigured models from executing API calls.
 *
 * The plugin ships configs for GPT-4.1 mini, Gemini 3 Flash, and Claude 4.5
 * Haiku. Additional models can be registered via the
 * `infyp_register_model_configs` action.
 *
 * Architecture:
 * - Singleton pattern for global coordination
 * - Lazy initialization on first access
 * - Extensible via `infyp_register_model_configs` action hook
 *
 * @package   IPAIS\Core\AI
 * @since 1.0.0
 * @author    InfyPress
 * @license   GPL-2.0-or-later
 */

namespace IPAIS\Core\AI;

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

/**
 * Model Config Registry Class
 *
 * Centralized store for AI model configurations. Every model that the plugin
 * can call must have a registered config containing its identity, provider,
 * call parameters, and pricing information.
 *
 * The registry is initialized lazily on first access. Built-in models are
 * registered internally, then the `infyp_register_model_configs` action fires
 * to allow extensions to register additional models.
 *
 * Usage Example:
 * ```php
 * $registry = INFYP_Model_Config_Registry::get_instance();
 *
 * // Check if a model is configured
 * if ( $registry->has_config( 'gpt-4.1-mini' ) ) {
 *     $config = $registry->get_config( 'gpt-4.1-mini' );
 *     $params = $registry->get_call_params( 'gpt-4.1-mini', 'full' );
 * }
 *
 * // Get dropdown data for settings page
 * $dropdown = $registry->get_dropdown_models();
 * // Returns: ['OpenAI' => ['gpt-4.1-mini' => 'GPT-4.1 mini (...)'], ...]
 * ```
 *
 * Hooks Fired:
 * - `infyp_register_model_configs`: Action to register custom model configs
 *
 * @package   IPAIS\Core\AI
 * @since 1.0.0
 *
 * @see       INFYP_AI_Provider_Manager For provider routing that consumes configs
 */
class INFYP_Model_Config_Registry {

	/**
	 * Singleton instance
	 *
	 * @since 1.0.0
	 * @var   INFYP_Model_Config_Registry|null
	 */
	private static ?self $instance = null;

	/**
	 * Registered model configurations
	 *
	 * Associative array keyed by model_id (and aliases). Each value is a
	 * complete model config array containing identity, provider, call_params,
	 * and pricing information.
	 *
	 * @since 1.0.0
	 * @var   array<string, array>
	 */
	private array $configs = [];

	/**
	 * Whether the registry has been initialized
	 *
	 * Set to true after free models are registered and the
	 * `infyp_register_model_configs` action has fired.
	 *
	 * @since 1.0.0
	 * @var   bool
	 */
	private bool $initialized = false;

	/**
	 * Private constructor (Singleton pattern)
	 *
	 * Empty by design. Initialization is deferred to init() which is
	 * called lazily on first public access.
	 *
	 * @since 1.0.0
	 */
	private function __construct() {}

	/**
	 * Get singleton instance
	 *
	 * Returns the single instance of the model config registry, creating
	 * it if it doesn't exist.
	 *
	 * @since 1.0.0
	 *
	 * @return self The singleton instance.
	 */
	public static function get_instance(): self {
		if ( self::$instance === null ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Prevent cloning of the singleton instance
	 *
	 * @since 1.0.0
	 */
	private function __clone() {}

	/**
	 * Prevent unserialization of the singleton instance
	 *
	 * @since 1.0.0
	 *
	 * @throws \Exception Always throws to prevent unserialization.
	 */
	public function __wakeup() {
		throw new \Exception( 'Cannot unserialize singleton' );
	}

	/**
	 * Initialize the registry (lazy)
	 *
	 * Called automatically on first public access. Registers the built-in
	 * model configs, then fires the `infyp_register_model_configs` action
	 * to allow extensions to register additional models.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	private function init(): void {
		if ( $this->initialized ) {
			return;
		}

		$this->register_builtin_models();

		/**
		 * Fires to allow extensions to register additional AI model configs.
		 *
		 * Extensions and third-party plugins hook here to register their
		 * model configurations. Each config must pass validation in register().
		 *
		 * @since 1.0.0
		 *
		 * @param INFYP_Model_Config_Registry $registry The registry instance.
		 *                                              Call $registry->register( $config ) to add models.
		 *
		 * @example
		 * add_action( 'infyp_register_model_configs', function( $registry ) {
		 *     $registry->register( [
		 *         'model_id'      => 'gpt-5-mini',
		 *         'display_name'  => 'GPT-5 mini',
		 *         'description'   => 'Cost-efficient reasoning',
		 *         'provider'      => 'openai',
		 *         'group'         => 'OpenAI',
		 *         'call_params'   => [
		 *             'full'         => ['max_completion_tokens' => 8192, 'temperature' => null, 'response_format' => ['type' => 'json_object'], 'extra' => ['reasoning_effort' => 'low']],
		 *             'keywords'     => ['max_completion_tokens' => 8192, 'temperature' => null, 'response_format' => ['type' => 'json_object'], 'extra' => ['reasoning_effort' => 'low']],
		 *             'single_field' => ['max_completion_tokens' => 8192, 'temperature' => null, 'response_format' => ['type' => 'json_object'], 'extra' => ['reasoning_effort' => 'low']],
		 *         ],
		 *         'pricing' => ['blended_per_million' => 3.00],
		 *     ] );
		 * } );
		 */
		do_action( 'infyp_register_model_configs', $this );

		$this->initialized = true;
	}

	/**
	 * Register the built-in model configurations
	 *
	 * Registers configs for the three built-in models:
	 * Gemini 3.1 Flash-Lite (Google), Claude 4.5 Haiku (Anthropic),
	 * and GPT-4.1 mini (OpenAI).
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	private function register_builtin_models(): void {

		// Gemini 3.1 Flash-Lite — most cost-effective frontier model, default.
		// Aliases the legacy 'gemini-3-flash-preview' ID so existing installs
		// that saved that value transparently migrate to Flash-Lite.
		$this->register( [
			'model_id'      => 'gemini-3.1-flash-lite-preview',
			'display_name'  => 'Gemini 3.1 Flash-Lite',
			'description'   => 'Most cost-effective frontier model',
			'provider'      => 'google',
			'group'         => 'Google',
			'prompt_format' => 'json_plain',
			'call_params'   => [
				'full'         => [ 'max_completion_tokens' => 2048, 'temperature' => null, 'response_format' => null, 'extra' => [] ],
				'keywords'     => [ 'max_completion_tokens' => 512, 'temperature' => null, 'response_format' => null, 'extra' => [] ],
				'single_field' => [ 'max_completion_tokens' => 512, 'temperature' => null, 'response_format' => null, 'extra' => [] ],
			],
			'pricing'       => [ 'blended_per_million' => 0.30 ],
			'api_model_id'  => 'gemini-3.1-flash-lite-preview',
			'aliases'       => [ 'gemini-3-flash-preview' ],
		] );

		// Claude 4.5 Haiku — fast, cost-effective Anthropic model.
		$this->register( [
			'model_id'      => 'claude-haiku-4-5',
			'display_name'  => 'Claude 4.5 Haiku',
			'description'   => 'Fast, cost-effective analysis',
			'provider'      => 'anthropic',
			'group'         => 'Anthropic',
			'prompt_format' => 'tool_use',
			'call_params'   => [
				'full'         => [ 'max_completion_tokens' => 1024, 'temperature' => null, 'response_format' => null, 'extra' => [] ],
				'keywords'     => [ 'max_completion_tokens' => 500, 'temperature' => null, 'response_format' => null, 'extra' => [] ],
				'single_field' => [ 'max_completion_tokens' => 500, 'temperature' => null, 'response_format' => null, 'extra' => [] ],
			],
			'pricing'       => [ 'blended_per_million' => 2.00 ],
			'api_model_id'  => 'claude-haiku-4-5-20251001',
			'aliases'       => [ 'claude-haiku-4-5-20251001' ],
		] );

		// GPT-4.1 mini — cost-effective OpenAI model.
		$this->register( [
			'model_id'      => 'gpt-4.1-mini',
			'display_name'  => 'GPT-4.1 mini',
			'description'   => 'Cost-effective image analysis',
			'provider'      => 'openai',
			'group'         => 'OpenAI',
			'prompt_format' => 'json',
			'call_params'   => [
				'full'         => [ 'max_completion_tokens' => 400, 'temperature' => 0.3, 'response_format' => [ 'type' => 'json_object' ], 'extra' => [] ],
				'keywords'     => [ 'max_completion_tokens' => 150, 'temperature' => 0.7, 'response_format' => [ 'type' => 'json_object' ], 'extra' => [] ],
				'single_field' => [ 'max_completion_tokens' => 150, 'temperature' => 0.7, 'response_format' => [ 'type' => 'json_object' ], 'extra' => [] ],
			],
			'pricing'       => [ 'blended_per_million' => 0.70 ],
		] );
	}

	/**
	 * Register a model configuration
	 *
	 * Validates the config array for required fields and structure, then stores
	 * it keyed by model_id. If the config includes aliases, those are also
	 * indexed to point to the same config by reference.
	 *
	 * Required fields: model_id, display_name, provider, group, call_params, pricing.
	 * Optional: description (shown in parentheses in dropdown).
	 * call_params must contain 'full', 'keywords', and 'single_field' keys, each
	 * with a 'max_completion_tokens' value.
	 *
	 * @since 1.0.0
	 *
	 * @param array $config The model configuration array. See class docblock for structure.
	 *
	 * @return bool True on successful registration, false on validation failure.
	 */
	public function register( array $config ): bool {

		// Validate required top-level fields.
		$required_fields = [ 'model_id', 'display_name', 'provider', 'group', 'call_params', 'pricing' ];

		foreach ( $required_fields as $field ) {
			if ( ! isset( $config[ $field ] ) || ( is_string( $config[ $field ] ) && '' === $config[ $field ] ) ) {
				return false;
			}
		}

		// Validate call_params structure.
		$required_call_types = [ 'full', 'keywords', 'single_field' ];

		if ( ! is_array( $config['call_params'] ) ) {
			return false;
		}

		foreach ( $required_call_types as $call_type ) {
			if ( ! isset( $config['call_params'][ $call_type ] ) || ! is_array( $config['call_params'][ $call_type ] ) ) {
				return false;
			}

			if ( ! isset( $config['call_params'][ $call_type ]['max_completion_tokens'] ) ) {
				return false;
			}
		}

		// Store the config keyed by model_id.
		$model_id                    = $config['model_id'];
		$this->configs[ $model_id ]  = $config;

		// Index aliases to point to the same config by reference.
		if ( ! empty( $config['aliases'] ) && is_array( $config['aliases'] ) ) {
			foreach ( $config['aliases'] as $alias ) {
				$this->configs[ $alias ] = &$this->configs[ $model_id ];
			}
		}

		return true;
	}

	/**
	 * Get the configuration for a model
	 *
	 * Returns the full config array for the given model_id (or alias).
	 * Triggers lazy initialization on first call.
	 *
	 * @since 1.0.0
	 *
	 * @param string $model_id The model identifier or alias.
	 *
	 * @return array|null The model config array, or null if not registered.
	 */
	public function get_config( string $model_id ): ?array {
		$this->init();

		return $this->configs[ $model_id ] ?? null;
	}

	/**
	 * Get call parameters for a specific model and call type
	 *
	 * Convenience method that returns just the call_params entry for
	 * the given call type (full, keywords, or single_field).
	 *
	 * @since 1.0.0
	 *
	 * @param string $model_id  The model identifier or alias.
	 * @param string $call_type The call type: 'full', 'keywords', or 'single_field'.
	 *
	 * @return array|null The call parameters array, or null if model or call type not found.
	 */
	public function get_call_params( string $model_id, string $call_type ): ?array {
		$config = $this->get_config( $model_id );

		if ( null === $config ) {
			return null;
		}

		return $config['call_params'][ $call_type ] ?? null;
	}

	/**
	 * Check whether a model has a registered configuration
	 *
	 * @since 1.0.0
	 *
	 * @param string $model_id The model identifier or alias.
	 *
	 * @return bool True if the model has a config, false otherwise.
	 */
	public function has_config( string $model_id ): bool {
		$this->init();

		return isset( $this->configs[ $model_id ] );
	}

	/**
	 * Get all registered model configurations
	 *
	 * Returns all configs, optionally filtered by provider slug. Aliases are
	 * excluded to avoid duplicate entries; only primary model_id entries are
	 * returned.
	 *
	 * @since 1.0.0
	 *
	 * @param string|null $provider Optional provider slug to filter by (e.g., 'openai', 'google', 'anthropic').
	 *
	 * @return array Associative array of model_id => config arrays.
	 */
	public function get_all_configs( ?string $provider = null ): array {
		$this->init();

		$result = [];

		foreach ( $this->configs as $key => $config ) {
			// Skip alias entries — only include primary model_id.
			if ( $key !== $config['model_id'] ) {
				continue;
			}

			if ( null !== $provider && $config['provider'] !== $provider ) {
				continue;
			}

			$result[ $key ] = $config;
		}

		return $result;
	}

	/**
	 * Get models grouped for dropdown rendering
	 *
	 * Returns an array structured for HTML `<optgroup>` rendering, grouped
	 * by the config's 'group' field (e.g., 'OpenAI', 'Google', 'Anthropic').
	 *
	 * @since 1.0.0
	 *
	 * @return array Grouped array: ['Google' => ['gemini-3.1-flash-lite-preview' => 'Gemini 3.1 Flash-Lite (...)'], 'OpenAI' => [...], ...]
	 */
	public function get_dropdown_models(): array {
		$all_configs = $this->get_all_configs();
		$grouped     = [];

		foreach ( $all_configs as $model_id => $config ) {
			$group = $config['group'];

			if ( ! isset( $grouped[ $group ] ) ) {
				$grouped[ $group ] = [];
			}

			$label = $config['display_name'];
			if ( ! empty( $config['description'] ) ) {
				$label .= ' (' . $config['description'] . ')';
			}
			$grouped[ $group ][ $model_id ] = $label;
		}

		return $grouped;
	}

	/**
	 * Get all valid model IDs
	 *
	 * Returns an array of all model_id values that have registered configs.
	 * Aliases are excluded; only primary model IDs are returned.
	 *
	 * @since 1.0.0
	 *
	 * @return array Indexed array of model_id strings.
	 */
	public function get_valid_model_ids(): array {
		$all_configs = $this->get_all_configs();

		return array_keys( $all_configs );
	}

	/**
	 * Get model labels map (short names only, no description).
	 *
	 * Returns model_id => display_name for all registered models.
	 * Used for the optimization spinner and other compact UI elements.
	 *
	 * @since 1.0.0
	 *
	 * @return array Associative array of model_id => display_name.
	 */
	public function get_model_labels(): array {
		$all_configs = $this->get_all_configs();
		$labels      = [];

		foreach ( $all_configs as $model_id => $config ) {
			$labels[ $model_id ] = $config['display_name'];
		}

		return $labels;
	}

	/**
	 * Get the blended pricing per million tokens for a model
	 *
	 * Returns the blended_per_million value from the model's pricing config.
	 * Falls back to 1.0 if the model is not registered or pricing is missing.
	 *
	 * @since 1.0.0
	 *
	 * @param string $model_id The model identifier or alias.
	 *
	 * @return float The blended cost per million tokens, or 1.0 as fallback.
	 */
	public function get_pricing( string $model_id ): float {
		$config = $this->get_config( $model_id );

		if ( null === $config || ! isset( $config['pricing']['blended_per_million'] ) ) {
			return 1.0;
		}

		return (float) $config['pricing']['blended_per_million'];
	}

	/**
	 * Resolve a model ID to its API model identifier
	 *
	 * Some models use a different identifier for API calls than their
	 * registry model_id. For example, 'claude-haiku-4-5' resolves to
	 * 'claude-haiku-4-5-20251001' for the Anthropic API.
	 *
	 * If the config has an 'api_model_id' field set, that value is returned.
	 * Otherwise, the original model_id is returned unchanged.
	 *
	 * @since 1.0.0
	 *
	 * @param string $model_id The model identifier or alias.
	 *
	 * @return string The API model identifier to use in API calls.
	 */
	public function resolve_api_model_id( string $model_id ): string {
		$config = $this->get_config( $model_id );

		if ( null !== $config && ! empty( $config['api_model_id'] ) ) {
			return $config['api_model_id'];
		}

		return $model_id;
	}
}
