<?php
/**
 * Thin client for calling the OpenAI / Anthropic chat completion APIs.
 *
 * @package Priizmo_Engine
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class Priizmo_Engine_AI_Client
 *
 * Dispatches a prompt to whichever provider owns the configured model and
 * returns the plain-text completion.
 */
class Priizmo_Engine_AI_Client {

	/**
	 * Default max output tokens for a generation call. Comfortably fits a
	 * short 2-3 sentence TL;DR summary; callers generating longer
	 * structured output (e.g. a 4-6 item FAQ array) should pass a larger
	 * value explicitly -- see MAX_TOKENS_FAQ.
	 *
	 * @var int
	 */
	const MAX_TOKENS_DEFAULT = 512;

	/**
	 * Max output tokens for FAQ generation. A 4-6 question/answer JSON
	 * array plus its structural overhead can easily exceed
	 * MAX_TOKENS_DEFAULT, especially with newer, more verbose models --
	 * when a provider hits its token cap mid-response, the output is
	 * truncated *before* the closing `]`, so it fails to parse as JSON at
	 * all (see Priizmo_Engine_Rest::extract_faq_items()). This previously
	 * failed silently: the API call itself succeeded (200 OK), so it
	 * never surfaced as a request error -- it just produced unparseable
	 * text, which callers only found out about once they checked the
	 * cached FAQ meta and found nothing there.
	 *
	 * @var int
	 */
	const MAX_TOKENS_FAQ = 2048;

	/**
	 * Generates a completion for the given prompt using the given model.
	 *
	 * @param string $prompt     Fully-built prompt (placeholders already replaced).
	 * @param string $model      Model slug, e.g. "gpt-5.6-terra" or "claude-sonnet-5".
	 * @param int    $max_tokens Maximum output tokens to request. Defaults to
	 *                           MAX_TOKENS_DEFAULT; pass MAX_TOKENS_FAQ (or
	 *                           similar) for longer structured output.
	 *
	 * @return string|WP_Error Completion text, or a WP_Error on failure.
	 */
	public static function generate( $prompt, $model, $max_tokens = self::MAX_TOKENS_DEFAULT ) {
		$provider = Priizmo_Engine_Settings::get_provider_for_model( $model );

		if ( null === $provider ) {
			return new WP_Error(
				'priizmo_engine_invalid_model',
				__( 'The configured default model is not recognized.', 'priizmo-ai-summary-faq-schema' ),
				array( 'status' => 400 )
			);
		}

		$api_key = Priizmo_Engine::get_setting( $provider . '_api_key', '' );

		if ( '' === $api_key ) {
			return new WP_Error(
				'priizmo_engine_missing_api_key',
				sprintf(
					/* translators: %s: AI provider name. */
					__( 'No API key is configured for %s. Add one under Settings > Priizmo AI Summary & FAQ Schema.', 'priizmo-ai-summary-faq-schema' ),
					'openai' === $provider ? 'OpenAI' : 'Anthropic'
				),
				array( 'status' => 400 )
			);
		}

		if ( 'openai' === $provider ) {
			return self::call_openai( $prompt, $model, $api_key, $max_tokens );
		}

		return self::call_anthropic( $prompt, $model, $api_key, $max_tokens );
	}

	/**
	 * HTTP request timeout (seconds) for a MAX_TOKENS_DEFAULT-sized call
	 * (e.g. a short summary). Generation time scales with output length,
	 * so a larger token budget (e.g. MAX_TOKENS_FAQ) gets a proportionally
	 * longer timeout via request_timeout_for() -- otherwise our own HTTP
	 * client could abort a slow-but-legitimately-still-generating FAQ
	 * response before the provider ever finishes, which would just cost
	 * an unnecessary retry instead of the increased token budget actually
	 * helping.
	 *
	 * @var int
	 */
	const REQUEST_TIMEOUT_DEFAULT = 45;

	/**
	 * Upper bound on the scaled timeout, regardless of how large
	 * $max_tokens is. wp_remote_post() runs inside a normal PHP request
	 * (an Action Scheduler tick, or the synchronous REST call from the
	 * editor), which is itself subject to the host's max_execution_time --
	 * scaling all the way up to match a very large token budget could
	 * exceed that and get the whole PHP process killed *before* our own
	 * timeout even fires. 120s comfortably covers MAX_TOKENS_FAQ-sized
	 * completions even at a pessimistic generation speed, while staying
	 * well under typical hosting execution limits.
	 *
	 * @var int
	 */
	const REQUEST_TIMEOUT_MAX = 120;

	/**
	 * Computes the HTTP timeout for a request requesting up to $max_tokens
	 * output tokens, scaling linearly from REQUEST_TIMEOUT_DEFAULT (never
	 * going below it, even for a smaller-than-default budget) up to
	 * REQUEST_TIMEOUT_MAX.
	 *
	 * @param int $max_tokens Requested max output tokens.
	 *
	 * @return int Timeout in seconds.
	 */
	private static function request_timeout_for( $max_tokens ) {
		$scaled = (int) ceil( self::REQUEST_TIMEOUT_DEFAULT * ( $max_tokens / self::MAX_TOKENS_DEFAULT ) );

		return max( self::REQUEST_TIMEOUT_DEFAULT, min( self::REQUEST_TIMEOUT_MAX, $scaled ) );
	}

	/**
	 * Calls the OpenAI Chat Completions API.
	 *
	 * @param string $prompt     Prompt text.
	 * @param string $model      OpenAI model slug.
	 * @param string $api_key    OpenAI API key.
	 * @param int    $max_tokens Maximum output tokens.
	 *
	 * @return string|WP_Error
	 */
	private static function call_openai( $prompt, $model, $api_key, $max_tokens ) {
		$response = wp_remote_post(
			'https://api.openai.com/v1/chat/completions',
			array(
				'timeout' => self::request_timeout_for( $max_tokens ),
				'headers' => array(
					'Content-Type'  => 'application/json',
					'Authorization' => 'Bearer ' . $api_key,
				),
				'body'    => wp_json_encode(
					array(
						'model'                 => $model,
						'messages'              => array(
							array(
								'role'    => 'user',
								'content' => $prompt,
							),
						),
						'temperature'           => 0.5,
						'max_completion_tokens' => $max_tokens,
					)
				),
			)
		);

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

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

		if ( 200 !== (int) $status ) {
			return new WP_Error(
				'priizmo_engine_api_error',
				isset( $body['error']['message'] ) ? $body['error']['message'] : __( 'The OpenAI API returned an error.', 'priizmo-ai-summary-faq-schema' ),
				array( 'status' => $status )
			);
		}

		if ( empty( $body['choices'][0]['message']['content'] ) ) {
			// See the matching check in call_anthropic(): an HTTP 200 with no
			// usable content is treated as retryable, so surface
			// `finish_reason` here too for the same diagnostic purpose.
			return new WP_Error(
				'priizmo_engine_empty_response',
				sprintf(
					/* translators: %s: OpenAI's reported finish_reason, or "unknown". */
					__( 'The OpenAI API returned an empty response (finish_reason: %s).', 'priizmo-ai-summary-faq-schema' ),
					isset( $body['choices'][0]['finish_reason'] ) ? $body['choices'][0]['finish_reason'] : 'unknown'
				)
			);
		}

		// A response cut off by the token cap is truncated *before* it ever
		// reaches JSON-parsing/validation elsewhere -- report it here
		// directly, since "finish_reason: length" identifies the real
		// cause precisely, instead of the caller only seeing a downstream
		// "couldn't be parsed" failure with no indication why.
		if ( isset( $body['choices'][0]['finish_reason'] ) && 'length' === $body['choices'][0]['finish_reason'] ) {
			return new WP_Error(
				'priizmo_engine_truncated_response',
				__( 'The OpenAI API response was truncated because it exceeded the maximum token limit.', 'priizmo-ai-summary-faq-schema' )
			);
		}

		return trim( $body['choices'][0]['message']['content'] );
	}

	/**
	 * Calls the Anthropic Messages API.
	 *
	 * @param string $prompt     Prompt text.
	 * @param string $model      Anthropic model slug.
	 * @param string $api_key    Anthropic API key.
	 * @param int    $max_tokens Maximum output tokens.
	 *
	 * @return string|WP_Error
	 */
	private static function call_anthropic( $prompt, $model, $api_key, $max_tokens ) {
		$response = wp_remote_post(
			'https://api.anthropic.com/v1/messages',
			array(
				'timeout' => self::request_timeout_for( $max_tokens ),
				'headers' => array(
					'Content-Type'      => 'application/json',
					'x-api-key'         => $api_key,
					'anthropic-version' => '2023-06-01',
				),
				'body'    => wp_json_encode(
					array(
						'model'      => $model,
						'max_tokens' => $max_tokens,
						'messages'   => array(
							array(
								'role'    => 'user',
								'content' => $prompt,
							),
						),
					)
				),
			)
		);

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

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

		if ( 200 !== (int) $status ) {
			return new WP_Error(
				'priizmo_engine_api_error',
				isset( $body['error']['message'] ) ? $body['error']['message'] : __( 'The Anthropic API returned an error.', 'priizmo-ai-summary-faq-schema' ),
				array( 'status' => $status )
			);
		}

		// The Messages API returns `content` as an array of typed blocks, not
		// necessarily a single text block at index 0 -- e.g. a model using
		// extended thinking prepends a `thinking` block (with no `text` key)
		// before the actual `text` block containing the real answer.
		// Assuming index 0 is always the text block previously caused every
		// thinking-capable response to be misreported as "empty" and burn
		// through all of its retries, even though the real answer was
		// present later in the same `content` array. Scan for the first
		// genuine text block instead.
		$text = null;

		if ( isset( $body['content'] ) && is_array( $body['content'] ) ) {
			foreach ( $body['content'] as $block ) {
				if ( isset( $block['type'], $block['text'] ) && 'text' === $block['type'] && '' !== $block['text'] ) {
					$text = $block['text'];
					break;
				}
			}
		}

		if ( null === $text ) {
			// Anthropic occasionally returns HTTP 200 with no usable text
			// content at all (e.g. a transient hiccup, or a genuine content
			// refusal) rather than a retryable error status. Surface
			// `stop_reason` in the message so recurring cases can be told
			// apart at a glance, and let the caller retry this like a rate
			// limit -- see Priizmo_Engine_Bulk::maybe_reschedule_on_error().
			return new WP_Error(
				'priizmo_engine_empty_response',
				sprintf(
					/* translators: %s: Anthropic's reported stop_reason, or "unknown". */
					__( 'The Anthropic API returned an empty response (stop_reason: %s).', 'priizmo-ai-summary-faq-schema' ),
					isset( $body['stop_reason'] ) ? $body['stop_reason'] : 'unknown'
				)
			);
		}

		// See the matching check in call_openai(): a response cut off by
		// the token cap is truncated before it ever reaches downstream
		// JSON parsing, so report it here directly with the precise cause
		// ("stop_reason: max_tokens") rather than a generic parse failure.
		if ( isset( $body['stop_reason'] ) && 'max_tokens' === $body['stop_reason'] ) {
			return new WP_Error(
				'priizmo_engine_truncated_response',
				__( 'The Anthropic API response was truncated because it exceeded the maximum token limit.', 'priizmo-ai-summary-faq-schema' )
			);
		}

		return trim( $text );
	}
}
