<?php
/** Execute exactly one durable BYOK provider request outside the web pool. */

defined( 'ABSPATH' ) || exit;

class Costudio_BYOK_Provider_Executor_Exception extends RuntimeException {}

class Costudio_BYOK_Provider_Executor {

	/** @var callable|null */
	private $adapter_factory;

	public function __construct( ?callable $adapter_factory = null ) {
		$this->adapter_factory = $adapter_factory;
	}

	/**
	 * @param callable $before_dispatch   Must durably persist the dispatch checkpoint or throw.
	 * @param callable $response_received Must durably persist the response checkpoint or throw.
	 * @param callable $heartbeat         Best-effort process/job heartbeat between blocking stages.
	 * @return array{text:string,provider:string,model:string,usage:array,elapsed_ms:int}
	 */
	public function execute(
		array $payload,
		callable $before_dispatch,
		callable $response_received,
		callable $heartbeat
	): array {
		$provider = sanitize_key( (string) ( $payload['provider'] ?? '' ) );
		$model    = sanitize_text_field( (string) ( $payload['model'] ?? '' ) );
		$api_key  = (string) ( $payload['api_key'] ?? '' );
		$prompt   = (string) ( $payload['prompt'] ?? '' );
		$context  = (array) ( $payload['context'] ?? array() );
		$deadline = max( 30, min( 540, (int) ( $payload['deadline_seconds'] ?? 540 ) ) );
		$started  = microtime( true );

		$adapter = $this->make_adapter( $provider, $api_key, $model );
		if ( ! method_exists( $adapter, 'set_durable_transport_hooks' ) ) {
			throw new Costudio_BYOK_Provider_Executor_Exception( 'byok_adapter_missing_transport_hooks' );
		}

		$dispatch_count = 0;
		$adapter->set_durable_transport_hooks(
			static function () use ( &$dispatch_count, $before_dispatch ): void {
				$dispatch_count++;
				if ( 1 !== $dispatch_count ) {
					throw new Costudio_BYOK_Provider_Executor_Exception( 'byok_multiple_dispatch_blocked' );
				}
				$before_dispatch();
			},
			$response_received
		);
		if ( method_exists( $adapter, 'disable_model_fallback' ) ) {
			$adapter->disable_model_fallback();
		}
		if ( method_exists( $adapter, 'set_temperature' ) ) {
			$adapter->set_temperature( $payload['temperature'] ?? null );
		}
		if ( method_exists( $adapter, 'set_thinking_budget' ) ) {
			$adapter->set_thinking_budget( (int) ( $payload['thinking_budget'] ?? -1 ) );
		}

		try {
			$heartbeat( 'preflight' );
			Costudio_Circuit_Breaker::check( $provider, $api_key );
			Costudio_Rate_Limiter::wait( $provider, $api_key );
			$heartbeat( 'provider_ready' );

			$elapsed   = (int) floor( microtime( true ) - $started );
			$remaining = $deadline - $elapsed;
			if ( $remaining < 5 ) {
				throw new Costudio_BYOK_Provider_Executor_Exception( 'byok_deadline_exhausted_before_dispatch' );
			}
			$provider_timeout = 'openai' === $provider ? 120 : 300;
			$adapter->set_http_timeout( max( 1, min( $provider_timeout, $remaining ) ) );

			$text = $adapter->generate( $prompt, $context );
			if ( '' === trim( (string) $text ) ) {
				throw new Costudio_BYOK_Provider_Executor_Exception( 'byok_provider_empty_result' );
			}
			$heartbeat( 'provider_finished' );
			Costudio_Circuit_Breaker::record_success( $provider, $api_key );

			return array(
				'text'       => (string) $text,
				'provider'   => $provider,
				'model'      => method_exists( $adapter, 'get_model' ) ? (string) $adapter->get_model() : $model,
				'usage'      => method_exists( $adapter, 'get_last_usage' )
					? Costudio_BYOK_Job_Codec::normalize_usage( (array) $adapter->get_last_usage() )
					: Costudio_BYOK_Job_Codec::normalize_usage( array() ),
				'elapsed_ms' => max( 0, (int) round( ( microtime( true ) - $started ) * 1000 ) ),
			);
		} catch ( Throwable $error ) {
			self::record_provider_failure( $provider, $api_key, $error );
			throw $error;
		}
	}

	public static function classify_error( Throwable $error ): array {
		$message = $error->getMessage();
		if ( false !== stripos( $message, '429' ) || false !== stripos( $message, 'rate limit' ) ) {
			return array( 'code' => 'provider_rate_limited', 'retry_safe' => true );
		}
		if ( preg_match( '/HTTP\s+([45]\d{2})/i', $message, $matches ) ) {
			$code = (int) $matches[1];
			return array(
				'code'       => $code >= 500 ? 'provider_http_5xx' : 'provider_http_4xx',
				'retry_safe' => $code >= 500 || 408 === $code || 409 === $code || 429 === $code,
			);
		}
		if ( preg_match( '/timeout|timed?\s*out|cURL\s+error\s+28/i', $message ) ) {
			return array( 'code' => 'provider_transport_timeout', 'retry_safe' => false );
		}
		if ( 0 === strpos( $message, 'byok_' ) ) {
			return array( 'code' => sanitize_key( $message ), 'retry_safe' => true );
		}
		return array( 'code' => 'provider_execution_failed', 'retry_safe' => false );
	}

	private function make_adapter( string $provider, string $api_key, string $model ) {
		if ( is_callable( $this->adapter_factory ) ) {
			return call_user_func( $this->adapter_factory, $provider, $api_key, $model );
		}
		switch ( $provider ) {
			case 'claude':
				return new Costudio_Claude_Adapter( $api_key, $model );
			case 'openai':
				return new Costudio_OpenAI_Adapter( $api_key, $model );
			case 'gemini':
				// Search results are assembled before submission; no hidden retry here.
				return new Costudio_Gemini_Adapter( $api_key, $model, false );
			default:
				throw new Costudio_BYOK_Provider_Executor_Exception( 'byok_provider_invalid' );
		}
	}

	private static function record_provider_failure( string $provider, string $api_key, Throwable $error ): void {
		$classification = self::classify_error( $error );
		if ( 'provider_rate_limited' === $classification['code'] ) {
			Costudio_Rate_Limiter::record_retry_after( $provider, $api_key, 60 );
		}
		if ( in_array( $classification['code'], array( 'provider_http_5xx', 'provider_transport_timeout' ), true ) ) {
			Costudio_Circuit_Breaker::record_failure( $provider, $api_key );
		}
	}
}
