<?php
/**
 * OpenAI Chat Model Implementation.
 *
 * Implements chat functionality for OpenAI's GPT models.
 *
 * @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://agenistrator.com/
 *
 * 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' ) ) {
	exit;
}

use Orhanerday\OpenAi\OpenAi;

/**
 * LLM_Chat_OpenAI class.
 */
class LLM_Chat_OpenAI extends LLM_Chat {
	/**
	 * Get the OpenAI client instance from the provider
	 *
	 * @return     OpenAi The OpenAI client
	 * @since     1.0.0
	 */
	protected function get_client() {
		return $this->provider->get_client();
	}

	/**
	 * Default temperature for API requests.
	 *
	 * Lower values (0.0-0.3) produce more deterministic, instruction-focused outputs.
	 * Higher values (0.7-1.0) produce more creative but potentially less predictable outputs.
	 *
	 * @var float
	 */
	protected const DEFAULT_TEMPERATURE = 0.3;

	/**
	 * Get additional API parameters (can be overridden by subclasses)
	 *
	 * Temperature is sent only when models.dev confirms the model supports it,
	 * since unsupported values are rejected by some reasoning models.
	 *
	 * @return     array Additional parameters to merge into API request
	 * @since     1.0.0
	 */
	protected function get_additional_params() {
		$params       = array();
		$provider_key = $this->provider->get_models_dev_key();

		if ( true === Model_Info::get_instance()->supports_temperature( $provider_key, $this->model_id ) ) {
			$params['temperature'] = static::DEFAULT_TEMPERATURE;
		}

		return $params;
	}

	/**
	 * Prepare API parameters for chat completion request
	 *
	 * @param      Context $context Context object containing messages and tools
	 * @param      bool $streaming Whether this is a streaming request
	 * @return     array API parameters ready for the request
	 */
	private function prepare_api_params( $context, $streaming = false ) {
		$messages = $context->render_messages();

		$params = array_merge(
			array(
				'model'    => $this->model_id,
				'messages' => $messages,
			),
			$this->get_additional_params()
		);

		if ( $streaming ) {
			$params['stream']         = true;
			$params['stream_options'] = array( 'include_usage' => true );
		}

		// Add tools if available
		$available_tools = $context->get_available_tools();
		if ( ! empty( $available_tools ) ) {
			$params['tools']       = $this->convert_tools_to_definitions( $available_tools );
			$params['tool_choice'] = 'auto';
		}

		// Fix empty arrays that should be objects for proper JSON encoding
		// PHP's json_encode() encodes empty [] but OpenAI API expects {} for certain fields
		$params = $this->fix_params_for_json_encoding( $params );

		return $params;
	}

	/**
	 * Fix empty arrays in params that should be encoded as JSON objects
	 *
	 * OpenAI API (and compatible APIs like OpenRouter) expect certain fields
	 * to be JSON objects, but PHP's json_encode() outputs [] for empty arrays.
	 * This method converts empty arrays to stdClass at paths that should be objects.
	 *
	 * Paths are relative to the params root. Wildcard patterns (*.key) match
	 * at any depth for JSON Schema fields that can appear nested.
	 *
	 * @param array $params The API parameters to fix
	 * @return array Fixed parameters with empty arrays converted to stdClass where needed
	 */
	private function fix_params_for_json_encoding( $params ) {
		// Paths that MUST be objects (per JSON Schema spec) or CAN be objects
		// These are relative to the params root for the chat completion API
		$object_paths = array(
			// Top-level params that are objects
			'logit_bias',                        // Map of token IDs to bias values
			'metadata',                          // Key-value metadata (not in chat completions, but included for compatibility)

			// JSON Schema fields in tool parameters (tools[*].function.parameters.*)
			// These follow JSON Schema spec where certain fields must be objects
			'*.properties',                      // Schema properties (MUST be object)
			'*.patternProperties',               // Pattern properties (MUST be object)
			'*.$defs',                           // Schema definitions (MUST be object)
			'*.definitions',                     // Legacy definitions (MUST be object)
			'*.dependentSchemas',                // Dependent schemas (MUST be object)
			'*.additionalProperties',            // Can be boolean or object - only convert if empty array
			'*.unevaluatedProperties',           // Can be boolean or object - only convert if empty array
			'*.items',                           // Array items schema (MUST be object or boolean)

			// JSON Schema in response_format.json_schema.schema
			// Uses same JSON Schema fields as tool parameters
			'response_format.json_schema.schema',
		);

		return String_Utils::fix_empty_arrays_to_objects( $params, $object_paths );
	}

	/**
	 * Convert a tool to an OpenAI-specific tool definition format
	 *
	 * @param      Tool $tool The tool to convert
	 * @return     array Tool definition in OpenAI format
	 * @since     1.0.0
	 */
	public function convert_tool_to_definition( $tool ) {
		$description              = $tool->get_description();
		$description_instructions = $tool->get_description_instructions();
		$instructions             = $tool->get_instructions();

		if ( '' !== $description_instructions ) {
			$description .= ' ' . $description_instructions;
		}
		if ( '' !== $instructions ) {
			$description .= "\n\n" . $instructions;
		}

		return array(
			'type'     => 'function',
			'function' => array(
				'name'        => $tool->get_id(),
				'description' => $description,
				'parameters'  => $tool->get_parameters(),
			),
		);
	}

	/**
	 * Create a new context object specific to OpenAI
	 *
	 * @param      string $system_message Optional custom system message
	 * @return     Context_OpenAI OpenAI-specific context object
	 * @since     1.0.0
	 */
	public function create_context( $system_message = null ) {
		return new Context_OpenAI( $system_message );
	}

	/**
	 * Validate that the context is the correct type for this provider
	 *
	 * @param      Context $context Context object to validate
	 * @throws     \Exception If context is not the correct type
	 * @since     1.0.0
	 */
	protected function validate_context( $context ) {
		if ( ! ( $context instanceof Context_OpenAI ) ) {
			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output
			throw new \Exception(
				String_Utils::replace_tags(
					__( 'Invalid context type: expected {expected}, got {actual}', 'agenistrator' ),
					array(
						'expected' => 'Context_OpenAI',
						'actual'   => get_class( $context ),
					)
				)
			);
			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}
	}

	/**
	 * Generate a synchronous (non-streaming) chat completion
	 *
	 * @param      Context $context Context object containing all request information
	 * @return     array Response data
	 * @since     1.0.0
	 */
	public function generate_sync_completion( $context ) {
		$this->validate_context( $context );

		$params = $this->prepare_api_params( $context, false );
		$client = $this->get_client();

		$response_json = $client->chat( $params );
		try {
			$response = String_Utils::decode_json( $response_json );
		} catch ( \Throwable $e ) {
			$response = array( 'error' => array( 'message' => $response_json ) );
		}
		$this->parse_error_response( $response );

		// Record token usage from API response (or estimate if not provided)
		if ( isset( $response['usage']['total_tokens'] ) ) {
			$usage_total = $response['usage']['total_tokens'];
		} else {
			$message     = $response['choices'][0]['message'] ?? array();
			$content     = $message['content'] ?? '';
			$tool_calls  = $message['tool_calls'] ?? array();
			$usage_total = $this->estimate_token_usage( $context, $content, $tool_calls );
		}
		Rate_Limiter::get_instance()->record_usage( $this, $usage_total );

		return $this->process_completion_response( $response );
	}

	/**
	 * Generate a streaming chat completion
	 *
	 * @param      Context $context Context object containing all request information
	 * @param      callable $stream_callback Callback function for streaming. Should return bool: true to continue, false to stop.
	 * @return     bool Success flag (true if streaming completed successfully)
	 * @since     1.0.0
	 */
	public function generate_streaming_completion( $context, $stream_callback = null ) {
		$this->validate_context( $context );

		try {
			$params = $this->prepare_api_params( $context, true );
			$client = $this->get_client();

			$content        = '';
			$tool_calls     = array();
			$usage          = null;
			$user_aborted   = false;
			$error_response = '';
			$sse_buffer     = '';

			try {
				$client->chat(
					$params,
					function ( $curl_handle, $data ) use ( $stream_callback, &$content, &$tool_calls, &$usage, &$user_aborted, &$error_response, &$sse_buffer ) {
						$original_len = strlen( $data );

						// Check HTTP status code to determine if this is an error response
						// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo -- cURL streaming callback requires direct cURL access
						$http_code = curl_getinfo( $curl_handle, CURLINFO_HTTP_CODE );
						if ( $http_code >= 400 ) {
							// Accumulate error response (not SSE format)
							$error_response .= $data;
							return $original_len;
						}

						// Prepend any buffered incomplete data from previous callback
						$data       = $sse_buffer . $data;
						$sse_buffer = '';

						// SSE messages are separated by \n\n - only process complete messages
						$last_separator = strrpos( $data, "\n\n" );
						if ( false === $last_separator ) {
							// No complete message yet, buffer everything for next callback
							$sse_buffer = $data;
							return $original_len;
						}

						// Buffer any incomplete trailing message for next callback
						$complete_data = substr( $data, 0, $last_separator + 2 );
						$sse_buffer    = substr( $data, $last_separator + 2 );

						// Parse SSE chunk (only complete messages)
						if ( ! $this->parse_sse_chunk( $complete_data, $content, $tool_calls, $usage, $stream_callback ) ) {
							$user_aborted = true;
							return 0;
						}
						return $original_len;
					}
				);
			} catch ( \Throwable $e ) {
				if ( ! $user_aborted ) {
					throw $e;
				}
			}

			// Handle HTTP error response
			if ( '' !== $error_response ) {
				try {
					$response = String_Utils::decode_json( $error_response );
				} catch ( \Throwable $e ) {
					$response = array( 'error' => array( 'message' => $error_response ) );
				}
				$this->parse_error_response( $response );
			}

			// Record token usage from streaming response (or estimate if not provided)
			if ( null !== $usage && isset( $usage['total_tokens'] ) ) {
				$usage_total = $usage['total_tokens'];
			} else {
				$usage_total = $this->estimate_token_usage( $context, $content, $tool_calls );
			}
			Rate_Limiter::get_instance()->record_usage( $this, $usage_total );

			// Handle user abort
			if ( $user_aborted ) {
				call_user_func( $stream_callback, array( 'type' => 'abort' ) );
				return true;
			}

			// Send done event
			call_user_func( $stream_callback, array( 'type' => 'done' ) );

			// Process tool calls if present
			if ( ! empty( $tool_calls ) ) {
				$tool_call_objects = $this->create_tool_call_objects( $tool_calls );

				foreach ( $tool_call_objects as $tool_call ) {
					if ( call_user_func(
						$stream_callback,
						array(
							'type'      => 'tool_call_complete',
							'tool_call' => $tool_call,
						)
					) === false ) {
						break;
					}
				}
			}

			return true;
		} catch ( \Throwable $e ) {
			call_user_func(
				$stream_callback,
				array(
					'type'    => 'error',
					'message' => $e->getMessage(),
				)
			);
			return false;
		}
	}

	/**
	 * Parse error response from API
	 *
	 * @param      array $response Decoded response array
	 * @throws     \Exception If response contains an error
	 */
	private function parse_error_response( $response ) {
		if ( isset( $response['error'] ) ) {
			$error_msg  = $response['error']['message'] ?? 'Unknown error';
			$error_type = $response['error']['type'] ?? 'api_error';
			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output
			throw new \Exception(
				String_Utils::replace_tags(
					__( 'API error ({type}): {message}', 'agenistrator' ),
					array(
						'type'    => $error_type,
						'message' => $error_msg,
					)
				)
			);
			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}
	}

	/**
	 * Parse SSE chunk and update content/tool_calls/usage state
	 *
	 * Per SSE spec:
	 * - Messages are separated by double newline (\n\n)
	 * - Each message consists of field lines in format "field: value\n"
	 * - Multiple consecutive "data:" lines within a message are concatenated with \n
	 * - Lines starting with ":" are comments and ignored
	 *
	 * @param      string $data Raw SSE data from cURL
	 * @param      string &$content Content accumulator (passed by reference)
	 * @param      array &$tool_calls Tool calls accumulator (passed by reference)
	 * @param      array|null &$usage Usage data accumulator (passed by reference)
	 * @param      callable $stream_callback Callback for streaming events
	 * @return     bool True to continue, false to abort
	 * @throws     \Exception If SSE stream contains an error
	 */
	private function parse_sse_chunk( $data, &$content, &$tool_calls, &$usage, $stream_callback ) {
		foreach ( explode( "\n\n", $data ) as $message ) {
			// Extract all data: lines from the message in one regex call
			if ( ! preg_match_all( '/^data:\s*(.*)$/m', $message, $matches ) ) {
				continue;
			}

			// Concatenate multiple data lines with newline (per SSE spec)
			$event_data = implode( "\n", $matches[1] );

			// Skip [DONE] message
			if ( '[DONE]' === $event_data ) {
				continue;
			}

			try {
				$response = String_Utils::decode_json( $event_data );
			} catch ( \Throwable $e ) {
				// Invalid JSON in SSE data - skip this message
				continue;
			}

			// Check for error in SSE stream
			$this->parse_error_response( $response );

			// Capture usage data (comes in final chunk with empty choices when stream_options.include_usage is true)
			if ( isset( $response['usage'] ) ) {
				$usage = $response['usage'];
			}

			// Skip chunks without choices (e.g., final usage-only chunk)
			if ( ! isset( $response['choices'][0] ) ) {
				continue;
			}

			$delta = $response['choices'][0]['delta'] ?? array();

			// Handle content chunks (skip empty content to avoid creating empty message elements)
			if ( ! empty( $delta['content'] ) ) {
				$content .= $delta['content'];
				if ( call_user_func(
					$stream_callback,
					array(
						'type'    => 'content',
						'content' => $delta['content'],
					)
				) === false ) {
					return false;
				}
			}

			// Handle tool calls
			if ( ! empty( $delta['tool_calls'] ) ) {
				foreach ( $delta['tool_calls'] as $tc ) {
					$index = $tc['index'] ?? 0;

					if ( ! isset( $tool_calls[ $index ] ) ) {
						$tool_calls[ $index ] = array(
							'id'       => '',
							'type'     => 'function',
							'function' => array(
								'name'      => '',
								'arguments' => '',
							),
						);
					}

					if ( isset( $tc['id'] ) ) {
						$tool_calls[ $index ]['id'] = $tc['id'];
					}
					if ( isset( $tc['type'] ) ) {
						$tool_calls[ $index ]['type'] = $tc['type'];
					}
					if ( isset( $tc['function']['name'] ) ) {
						$tool_calls[ $index ]['function']['name'] .= $tc['function']['name'];
					}
					if ( isset( $tc['function']['arguments'] ) ) {
						$tool_calls[ $index ]['function']['arguments'] .= $tc['function']['arguments'];
					}

					// Send tool call progress update
					if ( call_user_func(
						$stream_callback,
						array(
							'type'      => 'tool_call_progress',
							'index'     => $index,
							'id'        => $tool_calls[ $index ]['id'],
							'name'      => $tool_calls[ $index ]['function']['name'],
							'arguments' => $tool_calls[ $index ]['function']['arguments'],
						)
					) === false ) {
						return false;
					}
				}
			}
		}

		return true;
	}

	/**
	 * Create Tool_Call objects from raw tool call data
	 *
	 * @param      array $tool_calls_data Raw tool call data from API
	 * @return     array Array of Tool_Call objects
	 */
	private function create_tool_call_objects( $tool_calls_data ) {
		$tool_manager      = Tool_Manager::get_instance();
		$tool_call_objects = array();

		foreach ( $tool_calls_data as $tc ) {
			// Validate required fields
			if ( empty( $tc['function']['name'] ) || empty( $tc['id'] ) ) {
				continue;
			}

			// Get the tool instance
			$tool = $tool_manager->get_tool( $tc['function']['name'] );
			if ( ! $tool ) {
				continue;
			}

			// Parse JSON arguments
			$raw_arguments = $tc['function']['arguments'] ?? '';
			// Handle empty string as empty object
			if ( '' === $raw_arguments || null === $raw_arguments ) {
				$arguments = array();
			} else {
				try {
					$arguments = String_Utils::decode_json( $raw_arguments );
				} catch ( \Throwable $e ) {
					// Invalid JSON in tool call arguments, skip this tool call
					continue;
				}
			}

			$tool_call_objects[] = new Tool_Call( $tool, $arguments, $tc['id'] );
		}

		return $tool_call_objects;
	}

	/**
	 * Process a completion response from the OpenAI API
	 *
	 * @param      array $response The response from the API
	 * @return     array The processed response data
	 */
	private function process_completion_response( $response ) {
		$message = $response['choices'][0]['message'] ?? array();

		$result = array(
			'content' => $message['content'] ?? '',
		);

		// Add tool calls if present
		if ( ! empty( $message['tool_calls'] ) ) {
			$result['tool_calls'] = $this->create_tool_call_objects( $message['tool_calls'] );
		}

		return $result;
	}

	/**
	 * Estimate total token usage when API doesn't return usage data
	 *
	 * Uses Context's token counting methods for consistent estimation.
	 * Prompt tokens come from context, completion tokens from response content and tool calls.
	 * Converts raw API data to Message instances for provider-agnostic token counting.
	 *
	 * @param Context $context The context used for the request
	 * @param string $content Response content text
	 * @param array $tool_calls Raw tool call data from API ({id, function: {name, arguments}})
	 * @return int Estimated total token count
	 * @since     1.0.0
	 */
	protected function estimate_token_usage( $context, $content, $tool_calls = array() ) {
		$prompt_tokens = $context->count_tokens()['total'];

		// Build assistant message if there's content
		$assistant_message = ! empty( $content ) ? new Assistant_Message( $content ) : null;

		// Build tool call messages from raw API data
		$tool_call_messages = array();
		foreach ( $tool_calls as $tc ) {
			$name           = $tc['function']['name'] ?? '';
			$arguments_json = $tc['function']['arguments'] ?? '';
			$call_id        = $tc['id'] ?? '';

			if ( empty( $name ) || empty( $call_id ) ) {
				continue;
			}

			// Decode arguments JSON to array (Tool_Call_Message stores as array)
			$arguments = array();
			if ( ! empty( $arguments_json ) ) {
				try {
					$arguments = String_Utils::decode_json( $arguments_json );
				} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
					// Invalid JSON, use empty array
				}
			}

			$tool_call_messages[] = new Tool_Call_Message( $name, $arguments, $call_id );
		}

		$completion_tokens = $context->count_completion_tokens( $assistant_message, $tool_call_messages );
		return $prompt_tokens + $completion_tokens;
	}
}
