<?php

namespace Antimanual;

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

/**
 * OpenAI API Integration Class
 *
 * Handles all communication with the OpenAI API, including chat completions,
 * embeddings, and model management.
 *
 * @package Antimanual
 */
class OpenAI {
    private static $base_url    = 'https://api.openai.com';
    private static $api_version = 'v1';
    private        $api_key     = '';
    private        $model       = 'gpt-5-mini';
    private  $embed_model       = 'text-embedding-3-small';

    public function __construct() {
        $configs           = atml_get_openai_configs();
        $this->api_key     = $configs['api_key'] ?? atml_option( 'openai_api_key' );
        $this->model       = $configs['response_model'] ?? atml_get_default_openai_response_model();
    }

    /**
     * Get full API URL for an endpoint.
     *
     * @param string $endpoint The API endpoint.
     * @return string Full API URL.
     */
    public static function url( $endpoint = '' ) {
        return self::$base_url . '/' . self::$api_version . '/' . ltrim( $endpoint, '/' );
    }

    /**
     * Create a new conversation (legacy method for Assistants API).
     *
     * @param array $items Initial conversation items.
     * @return string|array Conversation ID or error array.
     */
    public function create_conversation( array $items ) {
        $items = array_map(
            fn( $item ) => [
                'type'    => $item['type'] ?? 'message',
                'role'    => $item['role'],
                'content' => $item['content'],
            ],
            $items
        );

        $conversation = $this->request(
            'conversations',
            'POST',
            [ 'items' => $items ],
        );

        if ( isset( $conversation['error'] ) || ! isset( $conversation['id'] ) ) {
            return [
                'error' => __( 'Failed to create conversation.', 'antimanual' ),
            ];
        }

        return $conversation['id'];
    }

    /**
     * Get AI reply for the given input
     * 
     * @param string|array $input             The input messages or content.
     * @param string       $conversation_id   Optional conversation ID for context.
     * @param string       $instructions      Optional system instructions.
     * @param int          $max_output_tokens Optional maximum output tokens (for controlling response length).
     * @return string|array The reply string, or an error array.
     */
    public function get_reply( $input, $conversation_id = '', $instructions = '', $max_output_tokens = 0 ) {
        $payload = [
            'model'        => $this->model,
            'conversation' => $conversation_id ?: null,
            'input'        => $input,
            'instructions' => $instructions ?: null,
        ];

        // Add max_tokens if specified (critical for long-form content generation).
        if ( $max_output_tokens > 0 ) {
            // Cap at 16000 tokens to stay within model limits.
            $payload['max_output_tokens'] = min( $max_output_tokens, 16000 );
        }

        $response = $this->request(
            'responses',
            'POST',
            $payload
        );

        // Check for incomplete responses (e.g., max_output_tokens exceeded).
        $status = $response['status'] ?? 'unknown';
        if ( 'incomplete' === $status ) {
            $reason = $response['incomplete_details']['reason'] ?? 'unknown';
            if ( 'max_output_tokens' === $reason ) {
                return [
                    'error' => __( 'The AI model ran out of tokens while generating content. This usually happens with reasoning models that use many tokens for "thinking". Please try a shorter article length or contact support.', 'antimanual' ),
                ];
            }
            return [
                'error' => sprintf(
                    /* translators: %s: reason for incomplete response */
                    __( 'AI response was incomplete: %s', 'antimanual' ),
                    $reason
                ),
            ];
        }

        $reply = self::extract_response_text( $response );

        if ( '' === $reply ) {
            return [
                'error' => __( 'Failed to get response from OpenAI.', 'antimanual' ),
            ];
        }

        return [
            'reply'           => $reply,
            'conversation_id' => $response['conversation_id'] ?? '',
        ];
    }

    /**
     * Get a reply while allowing the model to call tools (function calling).
     *
     * Runs the OpenAI Responses API tool-use loop: the model may emit
     * `function_call` items, which are executed by the supplied $executor and
     * fed back as `function_call_output` items until the model produces a final
     * text answer (or the iteration cap is reached, after which a final
     * tool-less call forces a text response).
     *
     * @param array    $input             Array of message objects.
     * @param string   $instructions      System instructions.
     * @param array    $tools             Tool specs: [ ['name','description','parameters'(schema)], ... ].
     * @param callable $executor          fn(string $name, array $args): array|\WP_Error.
     * @param int      $max_output_tokens Optional output token cap.
     * @return array { reply, conversation_id } or { error }.
     */
    public function get_reply_with_tools( array $input, string $instructions, array $tools, callable $executor, int $max_output_tokens = 0 ) {
        $tool_defs = [];
        foreach ( $tools as $tool ) {
            $tool_defs[] = [
                'type'        => 'function',
                'name'        => $tool['name'],
                'description' => $tool['description'] ?? '',
                'parameters'  => $tool['parameters'] ?? [ 'type' => 'object', 'properties' => [] ],
            ];
        }

        $messages       = $input;
        $max_iterations = 5;

        for ( $i = 0; $i < $max_iterations; $i++ ) {
            $payload = [
                'model'        => $this->model,
                'input'        => $messages,
                'instructions' => $instructions ?: null,
                'tools'        => $tool_defs,
            ];
            if ( $max_output_tokens > 0 ) {
                $payload['max_output_tokens'] = min( $max_output_tokens, 16000 );
            }

            $response = $this->request( 'responses', 'POST', $payload );

            if ( isset( $response['error'] ) ) {
                $msg = is_string( $response['error'] ) ? $response['error'] : ( $response['error']['message'] ?? __( 'AI request failed.', 'antimanual' ) );
                return [ 'error' => $msg ];
            }

            $output = is_array( $response['output'] ?? null ) ? $response['output'] : [];

            $calls = [];
            foreach ( $output as $item ) {
                if ( is_array( $item ) && 'function_call' === ( $item['type'] ?? '' ) ) {
                    $calls[] = $item;
                }
            }

            // No tool calls — we have the final answer.
            if ( empty( $calls ) ) {
                return [
                    'reply'           => self::extract_response_text( $response ),
                    'conversation_id' => '',
                ];
            }

            // Echo the model's output items back, then append each tool result.
            $messages = array_merge( $messages, $output );

            foreach ( $calls as $call ) {
                $args   = json_decode( (string) ( $call['arguments'] ?? '{}' ), true );
                $args   = is_array( $args ) ? $args : [];
                $result = call_user_func( $executor, (string) ( $call['name'] ?? '' ), $args );

                $output_text = is_wp_error( $result )
                    ? wp_json_encode( [ 'error' => $result->get_error_message() ] )
                    : wp_json_encode( $result, JSON_UNESCAPED_UNICODE );

                $messages[] = [
                    'type'    => 'function_call_output',
                    'call_id' => (string) ( $call['call_id'] ?? '' ),
                    'output'  => (string) $output_text,
                ];
            }
        }

        // Iteration cap hit: force a final text answer without tools.
        $final = $this->request(
            'responses',
            'POST',
            array_filter(
                [
                    'model'        => $this->model,
                    'input'        => $messages,
                    'instructions' => $instructions ?: null,
                ]
            )
        );

        if ( isset( $final['error'] ) ) {
            return [ 'error' => __( 'The assistant could not complete the request.', 'antimanual' ) ];
        }

        return [
            'reply'           => self::extract_response_text( $final ),
            'conversation_id' => '',
        ];
    }

    /**
     * Stream a reply from the OpenAI Responses API.
     *
     * Opens an HTTPS connection with `stream: true`, parses incoming SSE
     * frames, and invokes the supplied callback for each text delta as
     * it arrives. Mirrors get_reply() semantics for everything else
     * (model, instructions, max_output_tokens, conversation continuity);
     * does NOT use wp_remote_post because that buffers the whole response.
     *
     * The callback signature is fn(string $delta_text): void. It is called
     * zero or more times for `response.output_text.delta` events and never
     * for anything else — non-text events are absorbed silently.
     *
     * @param string|array $input             Input messages array (same shape as get_reply).
     * @param string       $conversation_id   Provider-side conversation id, or ''.
     * @param string       $instructions      System instructions, or ''.
     * @param int          $max_output_tokens Cap (0 = no cap). Clamped to 16000.
     * @param callable     $on_delta          fn(string $text_delta): void.
     * @return array{ok: bool, reply: string, conversation_id: string, error?: string}
     */
    public function stream_reply( $input, string $conversation_id, string $instructions, int $max_output_tokens, callable $on_delta ): array {
        if ( ! function_exists( 'atml_curl_stream_post' ) ) {
            return [ 'ok' => false, 'reply' => '', 'conversation_id' => '', 'error' => 'Streaming helper not loaded.' ];
        }

        $payload = [
            'model'        => $this->model,
            'conversation' => $conversation_id ?: null,
            'input'        => $input,
            'instructions' => $instructions ?: null,
            'stream'       => true,
        ];
        if ( $max_output_tokens > 0 ) {
            $payload['max_output_tokens'] = min( $max_output_tokens, 16000 );
        }

        $headers = [
            'Authorization: Bearer ' . $this->api_key,
            'Content-Type: application/json',
            'Accept: text/event-stream',
        ];

        $accumulated     = '';
        $final_conv_id   = '';
        $stream_error    = '';
        $sse_buffer      = '';

        $on_event = static function ( string $event_name, string $data_text ) use ( &$accumulated, &$final_conv_id, &$stream_error, $on_delta ) {
            if ( '' === $data_text || '[DONE]' === $data_text ) {
                return;
            }

            $payload = json_decode( $data_text, true );
            if ( ! is_array( $payload ) ) {
                return;
            }

            // Responses API: text deltas arrive as `response.output_text.delta`
            // with a `delta` string field. Older shapes also seen with `text`.
            if ( 'response.output_text.delta' === $event_name
                || 'response.output_text.delta' === ( $payload['type'] ?? '' ) ) {
                $delta = '';
                if ( isset( $payload['delta'] ) && is_string( $payload['delta'] ) ) {
                    $delta = $payload['delta'];
                } elseif ( isset( $payload['text'] ) && is_string( $payload['text'] ) ) {
                    $delta = $payload['text'];
                }
                if ( '' !== $delta ) {
                    $accumulated .= $delta;
                    $on_delta( $delta );
                }
                return;
            }

            // Conversation id surfaces in response.created / response.completed.
            if ( 'response.created' === $event_name || 'response.completed' === $event_name ) {
                if ( ! empty( $payload['response']['conversation_id'] ) ) {
                    $final_conv_id = (string) $payload['response']['conversation_id'];
                } elseif ( ! empty( $payload['conversation_id'] ) ) {
                    $final_conv_id = (string) $payload['conversation_id'];
                }
                return;
            }

            // OpenAI error event — store first error, frontend will surface.
            if ( 'error' === $event_name || isset( $payload['error'] ) ) {
                $stream_error = (string) ( $payload['error']['message'] ?? $payload['message'] ?? 'Unknown upstream error' );
            }
        };

        $on_chunk = static function ( string $bytes ) use ( &$sse_buffer, $on_event ) {
            atml_parse_sse_buffer( $bytes, $sse_buffer, $on_event );
        };

        $result = atml_curl_stream_post(
            self::url( 'responses' ),
            $headers,
            (string) wp_json_encode( $payload ),
            $on_chunk,
            180
        );

        if ( ! $result['ok'] ) {
            return [
                'ok'              => false,
                'reply'           => $accumulated,
                'conversation_id' => $final_conv_id,
                'error'           => $result['error'] ?? 'Streaming request failed.',
            ];
        }

        if ( '' !== $stream_error ) {
            return [
                'ok'              => false,
                'reply'           => $accumulated,
                'conversation_id' => $final_conv_id,
                'error'           => $stream_error,
            ];
        }

        return [
            'ok'              => true,
            'reply'           => $accumulated,
            'conversation_id' => $final_conv_id,
        ];
    }

    /**
     * Get messages for a specific conversation.
     *
     * @param string $conversation_id The conversation ID.
     * @return array Array of messages or error array.
     */
    public function get_messages ( string $conversation_id ) {
        $response = $this->request(
            "conversations/{$conversation_id}/items",
            'GET'
        );

        if ( isset( $response['error'] ) || ! isset( $response['data'] ) ) {
            return [
                'error' => __( 'Failed to retrieve messages for this conversation.', 'antimanual' ),
            ];
        }

        $conversation = array_map(
            fn( $item ) => [
                'id'      => $item['id'],
                'role'    => $item['role'],
                'type'    => $item['type'],
                'content' => self::extract_content_text( $item['content'] ?? [] ),
            ],
            $response['data'],
        );

        $messages = array_filter( $conversation, fn( $item ) => 'message' === $item['type'] );

        return $messages;
    }

    /**
     * Generate embedding for text content.
     *
     * @param string $content Text content to embed.
     * @return array|\WP_Error Embedding vector or WP_Error on failure.
     */
    public function generate_embedding( $content ) {
        $payload = [
            'model' => $this->embed_model,
            'input' => $content,
        ];

        // Phase 4: pass `dimensions` when the admin has opted into a smaller
        // output dim. Only valid for the text-embedding-3-* family; the
        // legacy ada-002 model silently ignores it but newer wrappers may
        // reject the unknown param, so we gate on the model name.
        $configured_dim = function_exists( 'atml_get_embedding_dimensions' )
            ? atml_get_embedding_dimensions( 'openai' )
            : 0;
        if ( $configured_dim > 0 && false !== strpos( $this->embed_model, 'text-embedding-3' ) ) {
            $payload['dimensions'] = $configured_dim;
        }

        $data = $this->request(
            '/embeddings',
            'POST',
            $payload
        );

        if ( is_wp_error( $data ) ) {
            return new \WP_Error( 'embedding_failed', __( 'Failed to generate embedding. Error: ', 'antimanual' ) . $data->get_error_message() );
        }

        if ( isset( $data['error'] ) ) {
            $error_msg = isset( $data['error']['message'] ) ? $data['error']['message'] : wp_json_encode( $data['error'] );
            return new \WP_Error( 'embedding_failed', __( 'Failed to generate embedding. Error: ', 'antimanual' ) . $error_msg );
        }

        $embedding = $data['data'][0]['embedding'] ?? null;

        if ( ! atml_is_embedding( $embedding ) ) {
            return new \WP_Error( 'embedding_failed', __( 'Failed to generate embedding. Unexpected response format.', 'antimanual' ) );
        }

        return $embedding;
    }

    /**
     * Get the embedding model name.
     *
     * @return string
     */
    public function get_embedding_model(): string {
        return $this->embed_model;
    }

	/**
	 * Generate an image using OpenAI's Image API.
	 *
	 * Uses the latest supported GPT Image model.
	 *
	 * @param string $prompt The image prompt.
	 * @param string $size   Image size. Default '1024x1024'.
	 * @return string|\WP_Error URL of the generated image or WP_Error.
	 */
		public function generate_image( string $prompt, string $size = '1024x1024' ) {
			$primary_model = trim(
				(string) apply_filters(
					'antimanual_openai_image_model',
                    'chatgpt-image-latest',
					$prompt,
					$size
				)
			);

		if ( '' === $primary_model ) {
                $primary_model = 'chatgpt-image-latest';
		}

		// Try the configured model first, then fall back through publicly available
		// alternatives. `chatgpt-image-latest` is not enabled on every OpenAI account —
		// when that's the case the request fails with "model not found / no access",
		// and we automatically retry with `gpt-image-1` and then `dall-e-3`.
		$candidates = array( $primary_model );
		foreach ( array( 'gpt-image-1', 'dall-e-3' ) as $fallback ) {
			if ( ! in_array( $fallback, $candidates, true ) ) {
				$candidates[] = $fallback;
			}
		}

		$last_error = '';
		foreach ( $candidates as $model ) {
			// 90s per attempt: image gen rarely takes more than ~30s. Failing
			// fast here means the three-model fallback chain can complete in
			// under 5 minutes even when an upstream model hangs.
			$response = $this->request(
				'images/generations',
				'POST',
				$this->build_image_generation_payload( $model, $prompt, $size ),
				90
			);

			if ( isset( $response['error'] ) ) {
				$last_error = $this->normalize_image_generation_error( $response['error'] );

				// Fall through when the model is unavailable OR the upstream
				// hung / refused the connection. Treating transient transport
				// failures as recoverable means a single slow image API call
				// no longer skips the rest of the fallback chain.
				if ( $this->is_model_unavailable_error( $last_error ) || $this->is_recoverable_image_transport_error( $last_error ) ) {
					error_log( sprintf( '[Antimanual] OpenAI image model "%s" failed, trying next fallback: %s', $model, $last_error ) );
					continue;
				}

				return new \WP_Error( 'image_generation_failed', $last_error );
			}

			$result = $this->extract_generated_image_url( $response );
			if ( ! is_wp_error( $result ) ) {
				return $result;
			}

			$last_error = $result->get_error_message();
		}

		return new \WP_Error(
			'image_generation_failed',
			'' !== $last_error ? $last_error : __( 'Image generation failed and no fallback model is available on this OpenAI account.', 'antimanual' )
		);
	}

	/**
	 * Detect "model not found / no access / org not verified" style errors so we
	 * can transparently retry with a different image model. Other errors (quota,
	 * policy, billing) are returned as-is so the user sees the real cause.
	 *
	 * @param string $message Normalized error message from the OpenAI API.
	 * @return bool
	 */
	private function is_model_unavailable_error( string $message ): bool {
		$needles = array(
			'model_not_found',
			'does not exist',
			'do not have access',
			"don't have access",
			'is not allowed',
			'unknown model',
			'invalid model',
			'unsupported model',
			// Triggered when the OpenAI org is not verified — currently required
			// for both `chatgpt-image-latest` and `gpt-image-1`, but NOT for
			// `dall-e-3`, so falling through gets the user a usable image.
			'must be verified',
			'organization must be verified',
			'organization verification',
			'verify organization',
			'access to the model',
		);
		$haystack = strtolower( $message );
		foreach ( $needles as $needle ) {
			if ( false !== strpos( $haystack, $needle ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * True for transport-layer image errors that may succeed on a retry with a
	 * different model — connection drops, gateway hiccups, or our own 90s
	 * timeout firing because the upstream model is congested. Auth/quota/policy
	 * errors are excluded so we don't burn the rate limit chasing identical
	 * 401s across the fallback chain.
	 *
	 * @param string $message Normalized error message from wp_remote_request.
	 * @return bool
	 */
	private function is_recoverable_image_transport_error( string $message ): bool {
		$needles = array(
			'timed out',
			'timeout',
			'operation timed out',
			'curl error 28',
			'curl error 7',
			'curl error 6',
			'connection refused',
			'connection reset',
			'could not resolve host',
			'gateway',
			'bad gateway',
			'service unavailable',
		);
		$haystack = strtolower( $message );
		foreach ( $needles as $needle ) {
			if ( false !== strpos( $haystack, $needle ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Build an OpenAI image-generation payload for the requested model.
	 *
	 * @param string $model  Image model identifier.
	 * @param string $prompt Image prompt.
	 * @param string $size   Requested image size.
	 * @return array
	 */
	private function build_image_generation_payload( string $model, string $prompt, string $size ): array {
		$payload = [
			'model'  => $model,
			'prompt' => $prompt,
			'n'      => 1,
			'size'   => $size,
		];

		if ( $this->is_gpt_image_model( $model ) ) {
			$payload['output_format'] = 'png';
		}

		return $payload;
	}

	/**
	 * Determine whether the given model is part of the GPT Image family.
	 *
	 * @param string $model Model identifier.
	 * @return bool
	 */
	private function is_gpt_image_model( string $model ): bool {
		return 0 === strpos( $model, 'gpt-image-' ) || 'chatgpt-image-latest' === $model;
	}

	/**
	 * Normalize OpenAI image errors into a readable message.
	 *
	 * @param mixed $error Error payload returned by the API.
	 * @return string
	 */
	private function normalize_image_generation_error( $error ): string {
		if ( is_array( $error ) ) {
			return (string) ( $error['message'] ?? __( 'Unknown error from OpenAI image generation.', 'antimanual' ) );
		}

		return is_string( $error ) && '' !== trim( $error )
			? $error
			: __( 'Unknown error from OpenAI image generation.', 'antimanual' );
	}

	/**
	 * Extract a usable image URL from an OpenAI image-generation response.
	 *
	 * GPT Image responses typically return base64 image data.
	 *
	 * @param array $response Decoded API response.
	 * @return string|\WP_Error
	 */
	private function extract_generated_image_url( array $response ) {
		$image_url = $response['data'][0]['url'] ?? '';

		if ( is_string( $image_url ) && '' !== trim( $image_url ) ) {
			return $image_url;
		}

		$image_data = $response['data'][0]['b64_json'] ?? '';

		if ( ! is_string( $image_data ) || '' === trim( $image_data ) ) {
			return new \WP_Error( 'image_generation_failed', __( 'No image data returned from OpenAI.', 'antimanual' ) );
		}

		$mime_type = $response['data'][0]['mime_type'] ?? 'image/png';

		return $this->store_generated_image( $image_data, $mime_type );
	}

	/**
	 * Persist base64 image data to a temporary file under uploads.
	 *
	 * @param string $image_data Base64-encoded image payload.
	 * @param string $mime_type  Mime type reported by the API.
	 * @return string|\WP_Error
	 */
	private function store_generated_image( string $image_data, string $mime_type = 'image/png' ) {
		$decoded = base64_decode( $image_data, true );

		if ( false === $decoded || '' === $decoded ) {
			return new \WP_Error( 'image_generation_failed', __( 'Failed to decode image data from OpenAI.', 'antimanual' ) );
		}

		$extension_map = [
			'image/png'  => 'png',
			'image/jpeg' => 'jpg',
			'image/webp' => 'webp',
			'image/gif'  => 'gif',
		];

		$extension = $extension_map[ strtolower( $mime_type ) ] ?? 'png';
		$upload_dir = wp_get_upload_dir();
		$temp_dir   = trailingslashit( $upload_dir['basedir'] ) . 'atml-temp';

		if ( ! file_exists( $temp_dir ) && ! wp_mkdir_p( $temp_dir ) ) {
			return new \WP_Error( 'image_generation_failed', __( 'Failed to prepare the temporary upload directory.', 'antimanual' ) );
		}

		$filename  = 'openai-image-' . wp_generate_uuid4() . '.' . $extension;
		$file_path = trailingslashit( $temp_dir ) . $filename;
		$file_url  = trailingslashit( $upload_dir['baseurl'] ) . 'atml-temp/' . $filename;

		$written = file_put_contents( $file_path, $decoded );

		if ( false === $written ) {
			return new \WP_Error( 'image_generation_failed', __( 'Failed to save generated image file.', 'antimanual' ) );
		}

		return $file_url;
	}

    /**
     * Fetch available models from OpenAI API.
     *
     * @return array|\WP_Error Array of available models or WP_Error on failure.
     */
    public static function list_models( string $api_key ) {
        if ( empty( $api_key ) ) {
            return new \WP_Error( 'missing_api_key', __( 'API key is required.', 'antimanual' ) );
        }

        $response = wp_remote_get(
            self::url( 'models' ),
            [
                'headers' => [
                    'Authorization' => 'Bearer ' . $api_key,
                    'Content-Type'  => 'application/json',
                ],
                'timeout' => 30,
            ]
        );

        if ( is_wp_error( $response ) ) {
            return new \WP_Error( 'request_failed', $response->get_error_message() );
        }

        $status_code = wp_remote_retrieve_response_code( $response );
        $body        = json_decode( wp_remote_retrieve_body( $response ), true );

        if ( 401 === $status_code ) {
            return new \WP_Error( 'invalid_api_key', __( 'Invalid API key. Please check your OpenAI API key.', 'antimanual' ) );
        }

        if ( 403 === $status_code ) {
            return new \WP_Error( 'access_denied', __( 'Access denied. Your API key may not have the required permissions.', 'antimanual' ) );
        }

        if ( $status_code < 200 || $status_code >= 300 ) {
            $error_message = $body['error']['message'] ?? __( 'Failed to fetch models from OpenAI.', 'antimanual' );
            return new \WP_Error( 'api_error', $error_message );
        }

        if ( ! isset( $body['data'] ) || ! is_array( $body['data'] ) ) {
            return new \WP_Error( 'invalid_response', __( 'Unexpected response format from OpenAI.', 'antimanual' ) );
        }

        // Filter and categorize models
        $chat_models = [];

        // Define known model prefixes for categorization
        $chat_prefixes = [ 'gpt-', 'o1', 'o3', 'chatgpt' ];

        foreach ( $body['data'] as $model ) {
            $model_id = $model['id'] ?? '';

            if ( empty( $model_id ) ) {
                continue;
            }



            // Check if it's a chat model
            foreach ( $chat_prefixes as $prefix ) {
                if ( strpos( $model_id, $prefix ) === 0 ) {
                    $chat_models[] = [
                        'value' => $model_id,
                        'label' => self::format_model_label( $model_id ),
                    ];
                    break;
                }
            }
        }

        // Sort models by name
        usort( $chat_models, fn( $a, $b ) => strcmp( $b['value'], $a['value'] ) );

        return [
            'valid'       => true,
            'chat_models' => $chat_models,
        ];
    }

    /**
     * Extract displayable text from a Responses API payload.
     *
     * @param array $response Decoded API response.
     * @return string
     */
    public static function extract_response_text( array $response ): string {
        $output_text = $response['output_text'] ?? '';
        if ( is_string( $output_text ) && '' !== trim( $output_text ) ) {
            return trim( $output_text );
        }

        $parts = [];

        foreach ( $response['output'] ?? [] as $item ) {
            if ( ! is_array( $item ) ) {
                continue;
            }

            $content_text = self::extract_content_text( $item['content'] ?? [] );
            if ( '' !== $content_text ) {
                $parts[] = $content_text;
            }
        }

        return trim( implode( "\n\n", $parts ) );
    }

    /**
     * Extract text from response or conversation content parts.
     *
     * @param array $content Content parts array from the OpenAI API.
     * @return string
     */
    public static function extract_content_text( array $content ): string {
        $parts = [];

        foreach ( $content as $part ) {
            if ( ! is_array( $part ) ) {
                continue;
            }

            $text = $part['text'] ?? '';

            if ( is_array( $text ) ) {
                $text = $text['value'] ?? '';
            }

            if ( is_string( $text ) && '' !== trim( $text ) ) {
                $parts[] = trim( $text );
            }
        }

        return trim( implode( "\n\n", $parts ) );
    }

    /**
     * Format a model ID into a human-readable label.
     *
     * @param string $model_id The model ID.
     * @return string The formatted label.
     */
    private static function format_model_label( string $model_id ): string {
        // Remove date suffixes like -2024-01-25
        $label = preg_replace( '/-\d{4}-\d{2}-\d{2}$/', '', $model_id );

        // Convert to title case with proper spacing
        $label = str_replace( [ '-', '_' ], ' ', $label );
        $label = ucwords( $label );

        // Fix common abbreviations
        $label = str_replace( [ 'Gpt', 'Ada', 'Turbo' ], [ 'GPT', 'Ada', 'Turbo' ], $label );

        return $label;
    }

    /**
     * Make an HTTP request to the OpenAI API.
     *
     * @param string $endpoint API endpoint.
     * @param string $method   HTTP method (GET, POST, etc.).
     * @param array  $payload  Request body parameters.
     * @param int    $timeout  Override the default 360s timeout. Image and
     *                         embedding calls should use a tighter value so a
     *                         hung upstream doesn't burn the whole generation
     *                         budget for one call.
     * @return array|mixed Decoded JSON response or error array.
     */
    private function request( string $endpoint, string $method = 'GET', array $payload = [], int $timeout = 360 ) {
        $args = [
            'method'  => $method,
            'headers' => [
                'Authorization' => 'Bearer ' . $this->api_key,
                'Content-Type'  => 'application/json',
            ],
            'timeout' => $timeout,
        ];

        if ( 'POST' === $method || 'PUT' === $method ) {
            $json = wp_json_encode( $payload, JSON_UNESCAPED_UNICODE, 2048 );

            if ( false === $json ) {
                return [
                    'error' => sprintf(
                        /* translators: %s: JSON encoding error message */
                        __( 'JSON encoding failed: %s', 'antimanual' ),
                        json_last_error_msg(),
                    ),
                ];
            }

            $args['body'] = $json;
        }

        $url      = $this->url( $endpoint );
        $response = wp_remote_request( $url, $args );

        if ( is_wp_error( $response ) ) {
            return [
                'error' => $response->get_error_message(),
            ];
        }

        return json_decode( wp_remote_retrieve_body( $response ), true );
    }

    /**
     * Fire several Responses API requests in parallel via curl_multi.
     *
     * Used by the chunked post generator so that an article with N sections
     * waits roughly one section's worth of time instead of N × one. The
     * sequential path was the dominant cost — for a 6-section article at
     * ~45s per call that's ~4.5 minutes saved.
     *
     * Each entry of $requests is a partial payload:
     *   [
     *     'input'             => array<int,array>,  // same shape as get_reply()
     *     'instructions'      => string (optional),
     *     'conversation_id'   => string (optional),
     *     'max_output_tokens' => int    (optional, capped at 16000),
     *   ]
     *
     * Returns the same keys with one of:
     *   [ 'reply' => string ]
     *   [ 'error' => string ]
     *
     * Falls back to sequential get_reply() when the cURL extension is missing.
     *
     * @param array<int|string,array> $requests
     * @return array<int|string,array>
     */
    public function get_replies_parallel( array $requests ): array {
        if ( empty( $requests ) ) {
            return [];
        }

        // Fallback path — should be rare on a WordPress host, but stay safe.
        if ( ! function_exists( 'curl_multi_init' ) ) {
            $out = [];
            foreach ( $requests as $key => $req ) {
                $reply = $this->get_reply(
                    $req['input'] ?? [],
                    (string) ( $req['conversation_id'] ?? '' ),
                    (string) ( $req['instructions'] ?? '' ),
                    (int) ( $req['max_output_tokens'] ?? 0 )
                );
                if ( is_array( $reply ) && isset( $reply['error'] ) ) {
                    $out[ $key ] = [ 'error' => (string) $reply['error'] ];
                } else {
                    $out[ $key ] = [ 'reply' => (string) $reply ];
                }
            }
            return $out;
        }

        $url     = self::url( 'responses' );
        $multi   = curl_multi_init();
        $handles = [];

        foreach ( $requests as $key => $req ) {
            $payload = [
                'model'        => $this->model,
                'conversation' => ! empty( $req['conversation_id'] ) ? $req['conversation_id'] : null,
                'input'        => $req['input'] ?? [],
                'instructions' => ! empty( $req['instructions'] ) ? $req['instructions'] : null,
            ];
            if ( ! empty( $req['max_output_tokens'] ) ) {
                $payload['max_output_tokens'] = min( (int) $req['max_output_tokens'], 16000 );
            }

            $json = wp_json_encode( $payload, JSON_UNESCAPED_UNICODE, 2048 );
            if ( false === $json ) {
                $handles[ $key ] = [ 'error' => __( 'JSON encoding failed for parallel request.', 'antimanual' ) ];
                continue;
            }

            $ch = curl_init();
            curl_setopt( $ch, CURLOPT_URL, $url );
            curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
            curl_setopt( $ch, CURLOPT_POST, true );
            curl_setopt( $ch, CURLOPT_POSTFIELDS, $json );
            curl_setopt( $ch, CURLOPT_HTTPHEADER, array(
                'Authorization: Bearer ' . $this->api_key,
                'Content-Type: application/json',
            ) );
            curl_setopt( $ch, CURLOPT_TIMEOUT, 360 );
            curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 15 );

            curl_multi_add_handle( $multi, $ch );
            $handles[ $key ] = $ch;
        }

        // Drive all handles concurrently. curl_multi_select() blocks until any
        // handle has activity, so the loop wakes early instead of spinning.
        $running = null;
        do {
            $status = curl_multi_exec( $multi, $running );
            if ( $running > 0 ) {
                curl_multi_select( $multi, 1.0 );
            }
        } while ( $running > 0 && CURLM_OK === $status );

        $results = array();
        foreach ( $handles as $key => $ch ) {
            // Pre-flight error from the encoding step above.
            if ( is_array( $ch ) ) {
                $results[ $key ] = $ch;
                continue;
            }

            $body      = curl_multi_getcontent( $ch );
            $err       = curl_error( $ch );
            $http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );

            curl_multi_remove_handle( $multi, $ch );
            curl_close( $ch );

            if ( '' !== $err ) {
                $results[ $key ] = array( 'error' => $err );
                continue;
            }

            $decoded = json_decode( (string) $body, true );

            if ( $http_code < 200 || $http_code >= 300 ) {
                $msg = is_array( $decoded ) && ! empty( $decoded['error']['message'] )
                    ? (string) $decoded['error']['message']
                    /* translators: %d: HTTP status code */
                    : sprintf( __( 'OpenAI API returned HTTP %d.', 'antimanual' ), $http_code );
                $results[ $key ] = array( 'error' => $msg );
                continue;
            }

            if ( ! is_array( $decoded ) ) {
                $results[ $key ] = array( 'error' => __( 'Invalid response from OpenAI.', 'antimanual' ) );
                continue;
            }

            if ( 'incomplete' === ( $decoded['status'] ?? '' ) ) {
                $reason = $decoded['incomplete_details']['reason'] ?? 'unknown';
                $results[ $key ] = array(
                    'error' => 'max_output_tokens' === $reason
                        ? __( 'AI ran out of tokens generating this section. Try a shorter article length.', 'antimanual' )
                        /* translators: %s: reason returned by the API */
                        : sprintf( __( 'AI response was incomplete: %s', 'antimanual' ), $reason ),
                );
                continue;
            }

            $reply = self::extract_response_text( $decoded );
            if ( '' === $reply ) {
                $results[ $key ] = array( 'error' => __( 'Empty response from OpenAI.', 'antimanual' ) );
                continue;
            }

            $results[ $key ] = array( 'reply' => $reply );
        }

        curl_multi_close( $multi );

        return $results;
    }
}
