<?php
/**
 * OpenAI Provider.
 *
 * Implements the provider interface for OpenAI's GPT models. Handles model
 * creation, API key management, and provider-specific settings. Uses the
 * openai-php/client package for API communication.
 *
 * @package   Agenistrator
 * @since     1.0.0
 * @author    Alex Ruban <alex@maximum.guru>
 * @author    Maximum Software Inc <contact@maximum.software>
 * @license   GPL-3.0-only
 * @link      https://maximum.software/
 *
 * SPDX-FileCopyrightText: 2025-2026 Alex Ruban <alex@maximum.guru>
 * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc <contact@maximum.software>
 * SPDX-License-Identifier: GPL-3.0-only
 */

namespace Agenistrator;

// Direct access prevention
if ( ! defined( 'ABSPATH' ) ) {
	return;
}

use Orhanerday\OpenAi\OpenAi;

/**
 * Provider_OpenAI class.
 */
class Provider_OpenAI extends Provider_Type {
	/**
	 * OpenAI client instance
	 * @var        OpenAi
	 * @since     1.0.0
	 */
	protected $client = null;

	/**
	 * Cached available models for this provider instance.
	 *
	 * @var array|null
	 * @since     1.0.0
	 */
	protected $available_models = null;

	/**
	 * Get the provider type ID
	 *
	 * @return     string Lowercase provider type identifier
	 * @since     1.0.0
	 */
	public function get_type_id() {
		return 'openai';
	}

	/**
	 * Default max tokens per minute for OpenAI
	 */
	public const DEFAULT_MAX_TOKENS_PER_MINUTE = 30000;

	/**
	 * Get the parameter definitions for this provider
	 *
	 * @return     array Array of parameter definitions
	 * @since     1.0.0
	 */
	public function get_parameter_definitions() {
		return array(
			'api_key'               => array(
				'name'        => 'API Key',
				'type'        => 'password',
				'description' => 'Your OpenAI API key. Get it from your OpenAI dashboard.',
				'required'    => true,
			),
			'organization_id'       => array(
				'name'        => 'Organization ID',
				'type'        => 'text',
				'description' => 'Optional: Your OpenAI organization ID if you belong to multiple organizations.',
				'required'    => false,
				'private'     => true,
			),
			'max_tokens_per_minute' => array(
				'name'        => 'Max Tokens Per Minute',
				'type'        => 'number',
				'description' => 'Rate limit in tokens per minute. Increase this if you have a higher API tier.',
				'required'    => false,
				'default'     => self::DEFAULT_MAX_TOKENS_PER_MINUTE,
				'min'         => 1000,
				'step'        => 1000,
			),
		);
	}

	/**
	 * Get the URL to obtain an API key for this provider
	 *
	 * @return     string URL to the OpenAI API keys page
	 * @since     1.0.0
	 */
	public function get_api_key_url() {
		return 'https://platform.openai.com/api-keys';
	}

	/**
	 * Get the max tokens per minute rate limit for this provider
	 *
	 * @return     int TPM limit (never null for OpenAI)
	 * @since     1.0.0
	 */
	public function get_max_tokens_per_minute() {
		return (int) $this->get_param( 'max_tokens_per_minute', self::DEFAULT_MAX_TOKENS_PER_MINUTE );
	}

	/**
	 * Get the required parameters for this provider
	 *
	 * @return     array Array of required parameter names
	 * @since     1.0.0
	 */
	public function get_required_parameters() {
		return array( 'api_key' );
	}

	/**
	 * Get the OpenAI client instance
	 *
	 * @return     OpenAi The OpenAI client
	 * @throws     \Exception If the API key is not set
	 * @since     1.0.0
	 */
	public function get_client() {
		if ( null === $this->client ) {
			$api_key = $this->get_param( 'api_key' );
			if ( empty( $api_key ) ) {
				throw new \Exception( 'API key is required for OpenAI API requests' );
			}
			$this->client = new OpenAi( $api_key );
			$base_url     = $this->get_client_base_url();
			if ( ! empty( $base_url ) ) {
				$this->client->setBaseURL( $base_url );
			}
			$headers = $this->get_client_headers();
			if ( ! empty( $headers ) ) {
				// Offset indices by 100 to avoid overwriting library's default headers at 0 and 1.
				$offset_headers = array();
				$index          = 100;
				foreach ( array_values( $headers ) as $header ) {
					$offset_headers[ $index++ ] = $header;
				}
				$this->client->setHeader( $offset_headers );
			}
			$org_id = $this->get_param( 'organization_id' );
			if ( ! empty( $org_id ) ) {
				$this->client->setORG( $org_id );
			}
		}
		return $this->client;
	}

	/**
	 * Override to customize base URL for OpenAI-compatible providers
	 *
	 * @return     string|null
	 * @since     1.0.0
	 */
	protected function get_client_base_url() {
		return null;
	}

	/**
	 * Override to inject additional headers for OpenAI-compatible providers
	 *
	 * @return     array
	 * @since     1.0.0
	 */
	protected function get_client_headers() {
		return array(
			'User-Agent: Agenistrator/' . AGENISTRATOR_VERSION,
		);
	}

	/**
	 * Create a Chat model instance
	 *
	 * @param      string $model_id The model ID
	 * @return     LLM_Chat_OpenAI Chat model instance
	 * @since     1.0.0
	 */
	public function create_chat_model( string $model_id ) {
		return new LLM_Chat_OpenAI( $model_id, $this );
	}

	/**
	 * Create an Embedding model instance
	 *
	 * @param      string $model_id The model ID
	 * @return     LLM_Embedding_OpenAI Embedding model instance
	 * @since     1.0.0
	 */
	public function create_embedding_model( string $model_id ) {
		return new LLM_Embedding_OpenAI( $model_id, $this );
	}

	/**
	 * Get all available models from the provider API
	 * @return     array Models categorized by type (chat and embedding)
	 * @since     1.0.0
	 */
	public function get_available_models() {
		if ( ! $this->is_configured() ) {
			throw new \Exception( 'Provider is not properly configured to get available models.' );
		}

		if ( null !== $this->available_models ) {
			return $this->available_models;
		}

		try {
			$client        = $this->get_client();
			$response_json = $client->listModels();

			try {
				$response = String_Utils::decode_json( $response_json );
			} catch ( \Throwable $e ) {
				throw new \Exception(
					String_Utils::replace_tags(
						__( 'Invalid API response: {error}', 'agenistrator' ),
						array( 'error' => $e->getMessage() )
					)
				);
			}

			if ( isset( $response['error'] ) && isset( $response['error']['message'] ) ) {
				throw new \Exception( $response['error']['message'] );
			}

			$models = array(
				'chat'      => array(),
				'embedding' => array(),
			);

			if ( isset( $response['data'] ) && is_array( $response['data'] ) ) {
				foreach ( $response['data'] as $model ) {
					$model_id = $model['id'];

					// Categorize models
					if ( strncmp( $model_id, 'gpt-', 4 ) === 0 && $this->is_chat_compatible( $model_id ) ) {
						$models['chat'][] = array(
							'id'   => $model_id,
							'name' => $this->build_model_display_name( $model_id ),
						);
					} elseif ( strncmp( $model_id, 'text-embedding-', 15 ) === 0 ) {
						$models['embedding'][] = array(
							'id'   => $model_id,
							'name' => $this->build_model_display_name( $model_id ),
						);
					}
				}
			}

			$this->available_models = $models;

			return $models;
		} catch ( \Throwable $e ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output
			throw new \Exception( 'Failed to retrieve models from OpenAI API: ' . $e->getMessage() );
		}
	}

	/**
	 * Get the default chat model ID
	 *
	 * @return     string|null Default chat model ID or null if not available
	 * @since     1.0.0
	 */
	public function get_default_chat_model() {
		$default = 'gpt-4o-mini';
		return $this->has_model( $default ) ? $default : null;
	}

	/**
	 * Get the default embedding model ID
	 *
	 * @return     string|null Default embedding model ID or null if not available
	 * @since     1.0.0
	 */
	public function get_default_embedding_model() {
		$default = 'text-embedding-3-small';
		return $this->has_model( $default ) ? $default : null;
	}

	/**
	 * Check if a model is compatible with chat functionality (text in/out with tools)
	 *
	 * Uses string-based heuristics since OpenAI's /v1/models API doesn't provide
	 * capability information.
	 *
	 * @param      string $model_id The model ID to check
	 * @return     bool True if the model is chat-compatible with tool support
	 * @since     1.0.0
	 */
	protected function is_chat_compatible( $model_id ) {
		// Exclude instruct models - they use the completions API, not chat
		if ( stripos( $model_id, '-instruct' ) !== false ) {
			return false;
		}

		// Exclude realtime and audio models - not for text chat
		if ( stripos( $model_id, '-realtime' ) !== false || stripos( $model_id, '-audio' ) !== false ) {
			return false;
		}

		// Exclude transcription/TTS models that might match gpt- prefix in the future
		if ( stripos( $model_id, '-transcribe' ) !== false || stripos( $model_id, '-tts' ) !== false ) {
			return false;
		}

		// Exclude image models
		if ( stripos( $model_id, '-image' ) !== false ) {
			return false;
		}

		return true;
	}

	/**
	 * Test if the provider configuration is valid by making an API call
	 *
	 * @throws     \Exception If configuration is invalid or API call fails
	 * @since     1.0.0
	 */
	public function test_configuration() {
		if ( ! $this->is_configured() ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output
			throw new \Exception( __( 'Provider is not configured', 'agenistrator' ) );
		}

		// Validate credentials by fetching models (will throw on failure)
		$this->get_available_models();
	}

	/**
	 * Build a display name for a model, appending model ID if display name differs
	 *
	 * Models.dev uses fuzzy matching, so different model variants may get the same display name.
	 * Appending the model ID ensures unique, distinguishable names in the UI.
	 *
	 * @param string $model_id The model ID
	 * @return string Display name with model ID appended if different
	 * @since     1.0.0
	 */
	protected function build_model_display_name( $model_id ) {
		$display_name = Model_Info::get_instance()->get_display_name( $this->get_models_dev_key(), $model_id );
		return ( $display_name !== $model_id ) ? "{$display_name} ({$model_id})" : $model_id;
	}
}
