<?php
/**
 * Canary gateway between the web pipeline and the durable local BYOK worker.
 *
 * The web runner may submit a request or read an existing result. It never
 * claims jobs, calls a provider, repairs leases or changes terminal states.
 */

defined( 'ABSPATH' ) || exit;

class Costudio_BYOK_Gateway {

	/**
	 * Create the durable boundary before prompt/search preparation starts.
	 *
	 * Existing attempts are deliberately left to run_step(), which owns result
	 * application and terminal error presentation. A newly accepted attempt
	 * always defers the web runner immediately.
	 *
	 * @throws Costudio_Generation_Deferred_Exception After a new job is accepted.
	 */
	public static function submit_pipeline_agent_if_needed( array $request ): void {
		$production_id = (int) ( $request['production_id'] ?? 0 );
		$agent_id      = (int) ( $request['agent_id'] ?? 0 );
		if (
			$production_id < 1
			|| $agent_id < 1
			|| $agent_id > 7
			|| ! Costudio_BYOK_Jobs::routes_new_jobs()
		) {
			return;
		}
		if ( Costudio_BYOK_Jobs::latest_attempt( $production_id, $agent_id ) ) {
			return;
		}

		self::repair_superseded_attempt_clock( $production_id, $agent_id );

		$request['execution_kind'] = 'pipeline_agent';
		$request['prompt']          = '';
		$request['context']         = array();
		$job = self::submit( $request );
		Costudio_Logger::info(
			'byok_jobs.pipeline_boundary_created',
			array(
				'job_id'        => (string) $job['job_id'],
				'production_id' => $production_id,
				'agent_id'      => $agent_id,
				'provider'      => (string) $job['provider'],
				'model'         => (string) $job['model'],
			),
			$production_id,
			$agent_id
		);

		throw new Costudio_Generation_Deferred_Exception( 'byok_durable_job_active' );
	}

	/**
	 * Return null when the legacy synchronous path must be used. Durable jobs
	 * already accepted are always read back, even after an operational rollback.
	 *
	 * @throws Costudio_Generation_Deferred_Exception While the CLI worker owns the attempt.
	 * @throws RuntimeException On an authoritative durable terminal failure.
	 */
	public static function maybe_generate( array $request ): ?string {
		$production_id = (int) ( $request['production_id'] ?? 0 );
		$agent_id      = (int) ( $request['agent_id'] ?? 0 );
		if ( $production_id < 1 || $agent_id < 1 || $agent_id > 7 ) {
			return null;
		}

		$job = Costudio_BYOK_Jobs::latest_attempt( $production_id, $agent_id );
		if ( ! $job ) {
			if ( ! Costudio_BYOK_Jobs::routes_new_jobs() ) {
				return null;
			}
			$job = self::submit( $request );
		}

		return self::read( $job, $production_id, $agent_id );
	}

	/**
	 * Read an already accepted attempt before selecting the current provider.
	 * This prevents a Settings change during execution from bypassing the job
	 * snapshot and dispatching the same agent through another provider.
	 */
	public static function read_existing( int $production_id, int $agent_id ): ?string {
		$job = Costudio_BYOK_Jobs::latest_attempt( $production_id, $agent_id );
		return $job ? self::read( $job, $production_id, $agent_id ) : null;
	}

	public static function mark_applied( int $production_id, int $agent_id ): void {
		$job = Costudio_BYOK_Jobs::latest_attempt( $production_id, $agent_id );
		if ( $job && in_array( (string) $job['status'], array( 'completed', 'failed', 'cancelled', 'uncertain' ), true ) ) {
			Costudio_BYOK_Jobs::mark_applied( (string) $job['job_id'] );
		}
	}

	private static function submit( array $request ): array {
		$encoded   = Costudio_BYOK_Job_Codec::encode_request( $request );
		$identity   = self::attempt_identity( (int) $request['production_id'], (int) $request['agent_id'] );
		$attempt_id = $identity['attempt_id'];
		$job = Costudio_BYOK_Jobs::create( array(
			'production_id'          => (int) $request['production_id'],
			'production_uid'         => Costudio_Production::tracking_uid_for( (int) $request['production_id'] ),
			'agent_id'               => (int) $request['agent_id'],
			'attempt_id'             => $attempt_id,
			'idempotency_key'        => $identity['idempotency_key'],
			'request_hash'           => $encoded['request_hash'],
			'provider'               => (string) $encoded['payload']['provider'],
			'model'                  => (string) $encoded['payload']['model'],
			'execution_profile_hash' => $encoded['execution_profile_hash'],
			'credential_fingerprint' => $encoded['credential_fingerprint'],
			'driver'                 => Costudio_BYOK_Jobs::MODE_EXTERNAL_CLI,
			'request_ciphertext'     => $encoded['ciphertext'],
		) );

		Costudio_Logger::info(
			! empty( $job['created_now'] ) ? 'byok_jobs.pipeline_submitted' : 'byok_jobs.pipeline_idempotent_replay',
			array(
				'job_id'        => (string) $job['job_id'],
				'attempt_id'    => (string) $job['attempt_id'],
				'production_id' => (int) $job['production_id'],
				'agent_id'      => (int) $job['agent_id'],
				'provider'      => (string) $job['provider'],
				'model'         => (string) $job['model'],
			),
			(int) $job['production_id'],
			(int) $job['agent_id']
		);

		return $job;
	}

	/**
	 * Build one stable identity from the production's persisted attempt clock.
	 * Concurrent web callbacks therefore replay the same insert instead of
	 * creating two jobs with unrelated random UUIDs.
	 *
	 * @return array{attempt_id:string,idempotency_key:string}
	 */
	private static function attempt_identity( int $production_id, int $agent_id ): array {
		$production = Costudio_Production::get_by_id( $production_id );
		$started_at = $production ? (string) ( $production->current_agent_started_at ?? '' ) : '';
		$previous   = method_exists( 'Costudio_BYOK_Jobs', 'latest_any_attempt' )
			? Costudio_BYOK_Jobs::latest_any_attempt( $production_id, $agent_id )
			: null;
		$predecessor = $previous
			? (string) ( $previous['job_id'] ?? '' ) . '|' . (string) ( $previous['superseded_at'] ?? '' )
			: 'first';
		$seed       = Costudio_Production::tracking_uid_for( $production_id ) . '|' . $agent_id . '|' . $started_at . '|' . $predecessor;
		$hex        = hash( 'sha256', 'costudio-byok-attempt|' . $seed );
		$uuid_hex   = substr( $hex, 0, 12 ) . '5' . substr( $hex, 13, 3 ) . 'a' . substr( $hex, 17, 15 );
		$attempt_id = substr( $uuid_hex, 0, 8 ) . '-'
			. substr( $uuid_hex, 8, 4 ) . '-'
			. substr( $uuid_hex, 12, 4 ) . '-'
			. substr( $uuid_hex, 16, 4 ) . '-'
			. substr( $uuid_hex, 20, 12 );

		return array(
			'attempt_id'      => $attempt_id,
			'idempotency_key' => hash( 'sha256', 'costudio-byok-idempotency|' . $seed ),
		);
	}

	/**
	 * A rewind may leave the previous attempt's recovered log timestamp in the
	 * production row. Rotate it before deriving a fresh idempotency identity.
	 */
	private static function repair_superseded_attempt_clock( int $production_id, int $agent_id ): void {
		$previous = method_exists( 'Costudio_BYOK_Jobs', 'latest_any_attempt' )
			? Costudio_BYOK_Jobs::latest_any_attempt( $production_id, $agent_id )
			: null;
		if ( ! $previous || empty( $previous['superseded_at'] ) ) {
			return;
		}

		$production = Costudio_Production::get_by_id( $production_id );
		if ( ! $production || (int) ( $production->current_agent ?? 0 ) !== $agent_id ) {
			return;
		}

		$started_ts    = strtotime( (string) ( $production->current_agent_started_at ?? '' ) . ' UTC' ) ?: 0;
		$superseded_ts = strtotime( (string) $previous['superseded_at'] . ' UTC' ) ?: 0;
		if ( $started_ts > 0 && $superseded_ts > 0 && $started_ts <= $superseded_ts ) {
			Costudio_Production::clear_current_agent_timing( $production_id, $agent_id );
			Costudio_Production::claim_current_agent_started_at( $production_id, $agent_id );
		}
	}

	private static function read( array $job, int $production_id, int $agent_id ): string {
		$status = (string) ( $job['status'] ?? '' );
		if ( in_array( $status, array( 'queued', 'processing', 'cancel_requested' ), true ) ) {
			throw new Costudio_Generation_Deferred_Exception( 'byok_durable_job_active' );
		}

		if ( 'completed' === $status ) {
			$ciphertext = (string) ( $job['result_ciphertext'] ?? '' );
			if ( '' === $ciphertext ) {
				throw new RuntimeException( __( 'The durable BYOK result is no longer available. Start a new agent attempt.', 'contentosapp-studio' ) );
			}
			$result = Costudio_BYOK_Job_Codec::decode_result( $ciphertext );
			Costudio_Logger::info(
				'byok_jobs.pipeline_result_read',
				array(
					'job_id'        => (string) $job['job_id'],
					'production_id' => $production_id,
					'agent_id'      => $agent_id,
				),
				$production_id,
				$agent_id
			);
			return (string) $result['text'];
		}

		$public = trim( (string) ( $job['error_public'] ?? '' ) );
		if ( '' === $public ) {
			if ( 'uncertain' === $status ) {
				$public = __( 'The durable BYOK worker lost the authoritative provider result after dispatch. It was not retried automatically because usage may have occurred.', 'contentosapp-studio' );
			} elseif ( 'cancelled' === $status ) {
				$public = __( 'The durable BYOK request was cancelled.', 'contentosapp-studio' );
			} else {
				$public = __( 'The durable BYOK worker could not complete this agent.', 'contentosapp-studio' );
			}
		}
		throw new RuntimeException( $public );
	}
}
