<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Client of the Aura service.
 *
 * One thing to do, and to do in a way that makes it clear when it is not working: pack the request,
 * send it, bring back the model's raw text. No model key lives here any more: the site knows only
 * its own licence, and the service pays for the tokens.
 *
 * NEGOTIATION, not configuration. The client declares on every request which directives its own
 * engine can render (see AIPB_Site_Payload::engine) and the server sends it only the chapters that
 * engine can print. Neither side has to know the other's version: it is the one thing that makes an
 * estate of installations we do not control upgradable — a site stuck on an old version goes on
 * working, it does not break.
 */
class AIPB_Remote {

	const OPT_KEY = 'aipb_server_key'; // Encrypted at rest, like every credential.

	/** The service's capabilities, seen through /health. Short cache: it is a negotiation, not data. */
	const HEALTH_CACHE = 'aipb_server_health';
	const HEALTH_TTL   = 300;
	// A service that is down is rechecked soon, but not on every page load.
	const HEALTH_FAIL_TTL = 60;

	/** Usage from the service: a number, not a real-time reading. */
	const USAGE_CACHE = 'aipb_server_usage';
	const USAGE_TTL   = 300;

	/**
	 * The service's address. It is not a site setting: it lives in the plugin — written into
	 * `AIPB_SERVER_URL` in the main file, and rewritten there at build time — because the service is
	 * always ours, and because a plugin that carries its own address goes on working when you move
	 * it or symlink it into another site.
	 *
	 * `AURA_SERVER_URL` in wp-config.php survives only as a PER-SITE override, for the rare case of
	 * one site out of several pointed somewhere else. It is not needed in normal development and it
	 * is not documented to the customer.
	 */
	public static function base_url() {
		if ( defined( 'AURA_SERVER_URL' ) && AURA_SERVER_URL ) {
			return untrailingslashit( (string) AURA_SERVER_URL );
		}

		return untrailingslashit( (string) AIPB_SERVER_URL );
	}

	/**
	 * The activation key, as the customer pastes it into the settings.
	 *
	 * It lives in the database, ENCRYPTED (see AIPB_Secret): it is the only route, as in any other
	 * paid plugin. The `AURA_SERVER_KEY` constant in wp-config.php is gone — it was a second source
	 * of truth that turned the settings screen into an explanation instead of a field, and you do
	 * not ask a customer who has just bought to open a PHP file.
	 */
	public static function key() {
		return AIPB_Secret::read_option( self::OPT_KEY );
	}

	public static function configured() {
		return '' !== self::base_url() && '' !== self::key();
	}

	/**
	 * The call's timeout (seconds).
	 *
	 * A generation takes minutes, and cutting it in half is the worst of both worlds: the model has
	 * been paid for and there is nothing to show. The filter uses the 0 = no timeout convention,
	 * which here becomes a huge value because WP/Requests does not treat 0 as unlimited (it ends up
	 * in cURL's sub-second branch and the request fails after ~1s).
	 */
	protected static function timeout() {
		$t = (int) apply_filters( 'aipb_server_timeout', 900 );

		return $t > 0 ? $t : 86400;
	}

	/**
	 * The capabilities the service declares. Empty (and not an error) when it does not answer:
	 * whoever uses it has to be able to decide for themselves whether that is a problem.
	 *
	 * The FAILURE is cached too, for less time. Without that, an unreachable service would make
	 * EVERY opening of the Studio wait ten seconds — and the Studio is opened just to look at a
	 * section that is already done, which has nothing to do with the service.
	 */
	public static function health( $force = false ) {
		if ( ! $force ) {
			$cached = get_transient( self::HEALTH_CACHE );
			if ( is_array( $cached ) ) {
				return $cached;
			}
		}

		$url = self::base_url();
		if ( '' === $url ) {
			return array();
		}

		$resp = wp_remote_get( $url . '/health', array( 'timeout' => 10 ) );
		if ( is_wp_error( $resp ) || 200 !== wp_remote_retrieve_response_code( $resp ) ) {
			set_transient( self::HEALTH_CACHE, array(), self::HEALTH_FAIL_TTL );

			return array();
		}

		$data = json_decode( wp_remote_retrieve_body( $resp ), true );
		$data = is_array( $data ) ? $data : array();
		set_transient( self::HEALTH_CACHE, $data, self::HEALTH_TTL );

		return $data;
	}

	/**
	 * Throws away what is cached about the service.
	 *
	 * The USAGE too, and not only the capabilities: after a key change those numbers belong to
	 * another account, and showing them for the five minutes of the TTL means telling a customer who
	 * has just arrived that they have already spent — or telling whoever has just rotated their key
	 * that the bill has been zeroed. The service's capabilities do not change, the bill does.
	 */
	public static function flush_health() {
		delete_transient( self::HEALTH_CACHE );
		delete_transient( self::USAGE_CACHE );
	}

	/**
	 * This licence's usage, from the service.
	 *
	 * The plugin keeps a log of its own (AIPB_Usage), but it is a reminder: it only sees the
	 * generations that went through THIS site, and it lives in a database the customer can empty.
	 * The accounting is on the other side, and that is the one that counts when an invoice is
	 * discussed. Short cache: it is a number, not a real-time reading.
	 *
	 * @return array|WP_Error
	 */
	public static function usage( $force = false ) {
		$cached = get_transient( self::USAGE_CACHE );
		if ( ! $force && is_array( $cached ) ) {
			return $cached;
		}

		$url = self::base_url();
		$key = self::key();
		if ( '' === $url || '' === $key ) {
			return self::unconfigured();
		}

		$resp = wp_remote_get( $url . '/v1/usage', array(
			'timeout' => 15,
			'headers' => array( 'Authorization' => 'Bearer ' . $key ),
		) );

		if ( is_wp_error( $resp ) ) {
			return new WP_Error( 'aipb_server_net', $resp->get_error_message(), array( 'status' => 502 ) );
		}

		$code = (int) wp_remote_retrieve_response_code( $resp );
		$data = json_decode( wp_remote_retrieve_body( $resp ), true );
		$data = is_array( $data ) ? $data : array();

		if ( 200 !== $code || empty( $data['ok'] ) ) {
			return self::server_error( $code, $data );
		}

		set_transient( self::USAGE_CACHE, $data, self::USAGE_TTL );

		return $data;
	}

	/**
	 * Can the service do this? It declares so itself in /health.
	 *
	 * It is the client half of the negotiation: the plugin does not know the service's version and
	 * must not know it — it asks for a capability and adapts. A service older than the plugin stays
	 * perfectly usable, it simply receives the shape it can read.
	 */
	public static function supports( $feature ) {
		$health   = self::health();
		$features = isset( $health['features'] ) && is_array( $health['features'] ) ? $health['features'] : array();

		return in_array( $feature, $features, true );
	}

	/** Can the service deliver a generation as it produces it, and can this site read that? */
	public static function supports_streaming() {
		return self::supports( 'streaming' ) && function_exists( 'curl_init' );
	}

	/** The request body: identical for the synchronous route and the streaming one. */
	protected static function payload( $prompt, $context, $mode, $images, $fidelity, $reproduce = false ) {
		$body = array(
			'mode'     => $mode,
			'prompt'   => $prompt,
			'fidelity' => $fidelity,
			'engine'   => AIPB_Site_Payload::engine(),
			'context'  => self::context( $context ),
			'site'     => AIPB_Site_Payload::site(),
		);

		if ( ! empty( $images ) ) {
			$body['images'] = array_values( $images );
		}

		// Reproducing an existing page: it inverts the design chapter on the service, so it only
		// travels when it is true — an older service ignores it and keeps its usual behaviour.
		if ( $reproduce ) {
			$body['reproduce'] = true;
		}

		return $body;
	}

	/**
	 * The key is missing — or, far more rarely, the address stamped into the zip.
	 *
	 * The message names ONE thing only because there is only one the customer can do: paste the key.
	 * If the address is missing the plugin was packaged badly, and sending them looking for a field
	 * that does not exist would be the worst possible direction.
	 */
	protected static function unconfigured() {
		return new WP_Error(
			'aipb_server_unconfigured',
			'' === self::base_url()
				? __( 'This copy of the plugin has no service address: download it again from your Aura dashboard.', 'aipb' )
				: __( 'The activation key is missing: paste it into Aura → Settings.', 'aipb' ),
			array( 'status' => 428 )
		);
	}

	/**
	 * Asks the service for a generation.
	 *
	 * @return array{raw:string,usage:array,model:string,warning:string,request_id:string}|WP_Error
	 */
	public static function generate( $prompt, $context, $mode, $images, $fidelity, $reproduce = false ) {
		$url = self::base_url();
		$key = self::key();

		if ( '' === $url || '' === $key ) {
			return self::unconfigured();
		}

		$body = self::payload( $prompt, $context, $mode, $images, $fidelity, $reproduce );

		// Idempotency key: derived from the request's CONTENT, not at random. If the connection
		// drops halfway and WordPress retries, or the user presses the same thing again within a
		// few minutes, the service returns the earlier generation instead of producing — and
		// charging for — a second one. Different requests have different fingerprints, so two
		// distinct instructions never steal each other's answer.
		$idem = substr( hash( 'sha256', wp_json_encode( $body ) ), 0, 40 );

		$resp = wp_remote_post( $url . '/v1/generate', array(
			'timeout' => self::timeout(),
			'headers' => array(
				'Authorization'   => 'Bearer ' . $key,
				'Content-Type'    => 'application/json',
				'Idempotency-Key' => $idem,
			),
			'body'    => wp_json_encode( $body ),
		) );

		if ( is_wp_error( $resp ) ) {
			return new WP_Error(
				'aipb_server_net',
				__( 'The Aura service cannot be reached: ', 'aipb' ) . $resp->get_error_message(),
				array( 'status' => 502 )
			);
		}

		$code = (int) wp_remote_retrieve_response_code( $resp );
		$data = json_decode( wp_remote_retrieve_body( $resp ), true );
		$data = is_array( $data ) ? $data : array();

		if ( 200 !== $code || empty( $data['ok'] ) ) {
			return self::server_error( $code, $data );
		}

		if ( ( ! isset( $data['raw'] ) || '' === trim( (string) $data['raw'] ) ) && empty( $data['ops'] ) ) {
			return new WP_Error( 'aipb_server_empty', __( 'The Aura service returned no content at all.', 'aipb' ), array( 'status' => 502 ) );
		}

		return self::answer( $data );
	}

	/**
	 * A page, opened by the service's browser and brought back as something rebuildable.
	 *
	 * It calls no model and spends no credit: what comes back is a screenshot, the page split into
	 * sections, and for each one its markup plus the styles the browser resolved. The generating
	 * happens afterwards, one section at a time, down the ordinary road — which is what keeps the
	 * result an Aura section, with its placeholders and its tokens, instead of somebody else's HTML
	 * pasted into a page and editable by nobody.
	 *
	 * The timeout is its own and long: a browser has to open the page, wait for it to settle and
	 * walk down it to wake the lazy images. Four seconds is a good site, forty is a heavy one.
	 *
	 * @return array|WP_Error
	 */
	public static function capture( $url_to_open, $width = 1440, $max_sections = 12 ) {
		$url = self::base_url();
		$key = self::key();

		if ( '' === $url || '' === $key ) {
			return self::unconfigured();
		}

		$resp = wp_remote_post( $url . '/v1/capture', array(
			'timeout' => 120,
			'headers' => array(
				'Authorization' => 'Bearer ' . $key,
				'Content-Type'  => 'application/json',
			),
			'body'    => wp_json_encode( array(
				'url'          => (string) $url_to_open,
				'width'        => (int) $width,
				'max_sections' => (int) $max_sections,
			) ),
		) );

		if ( is_wp_error( $resp ) ) {
			return new WP_Error(
				'aipb_server_net',
				__( 'The Aura service cannot be reached: ', 'aipb' ) . $resp->get_error_message(),
				array( 'status' => 502 )
			);
		}

		$code = (int) wp_remote_retrieve_response_code( $resp );
		$data = json_decode( wp_remote_retrieve_body( $resp ), true );
		$data = is_array( $data ) ? $data : array();

		if ( 200 !== $code || empty( $data['ok'] ) ) {
			return self::server_error( $code, $data );
		}

		return $data;
	}

	/**
	 * Like generate(), but the text arrives as it is produced.
	 *
	 * A generation takes minutes and until now the Studio stayed mute for all of that time: it is
	 * not only perception, it is that without feedback «it is working» cannot be told apart from
	 * «it is stuck», and the only possible reaction was to reload — throwing away a generation that
	 * had already been paid for.
	 *
	 * cURL is used here instead of WordPress's HTTP API, and it is not a preference: wp_remote_post
	 * delivers the body all at once at the end, which is exactly what streaming has to avoid. The
	 * fallback is still the synchronous route, for sites without cURL.
	 *
	 * @param callable      $on_delta function( string $text ): void — called for every piece.
	 * @param callable|null $on_meta  function( array $info ): void — the stream's opening event. It
	 *                                carries the SERVICE's own verdict on whether it will really be
	 *                                able to deliver in pieces, which depends on the model provider
	 *                                it is configured with and which no one on this side can guess.
	 *
	 * @return array{raw:string,usage:array,model:string,warning:string,request_id:string}|WP_Error
	 */
	public static function generate_stream( $prompt, $context, $mode, $images, $fidelity, $on_delta, $on_meta = null, $reproduce = false ) {
		$url = self::base_url();
		$key = self::key();

		if ( '' === $url || '' === $key ) {
			return self::unconfigured();
		}
		if ( ! function_exists( 'curl_init' ) ) {
			return self::generate( $prompt, $context, $mode, $images, $fidelity );
		}

		$body   = self::payload( $prompt, $context, $mode, $images, $fidelity, $reproduce );
		$buffer = '';
		$done   = null;
		$fail   = null;

		$ch = curl_init( $url . '/v1/generate/stream' );
		curl_setopt_array( $ch, array(
			CURLOPT_POST           => true,
			CURLOPT_RETURNTRANSFER => true,
			CURLOPT_TIMEOUT        => self::timeout(),
			CURLOPT_CONNECTTIMEOUT => 15,
			CURLOPT_HTTPHEADER     => array(
				'Content-Type: application/json',
				'Accept: text/event-stream',
				'Authorization: Bearer ' . $key,
			),
			CURLOPT_POSTFIELDS     => wp_json_encode( $body ),
			// The body comes out of here in pieces. An event can be split in half between two
			// chunks: the incomplete tail is kept aside and joined onto the next one.
			CURLOPT_WRITEFUNCTION  => function ( $ch, $chunk ) use ( &$buffer, &$done, &$fail, $on_delta, $on_meta ) {
				$buffer .= $chunk;
				while ( false !== ( $sep = strpos( $buffer, "\n\n" ) ) ) {
					$block  = substr( $buffer, 0, $sep );
					$buffer = substr( $buffer, $sep + 2 );

					$event = 'message';
					$data  = '';
					foreach ( explode( "\n", $block ) as $line ) {
						if ( 0 === strpos( $line, 'event:' ) ) {
							$event = trim( substr( $line, 6 ) );
						} elseif ( 0 === strpos( $line, 'data:' ) ) {
							$data .= trim( substr( $line, 5 ) );
						}
					}

					$payload = '' !== $data ? json_decode( $data, true ) : null;
					if ( ! is_array( $payload ) ) {
						continue;
					}

					if ( 'delta' === $event && isset( $payload['text'] ) ) {
						call_user_func( $on_delta, (string) $payload['text'] );
					} elseif ( 'meta' === $event && $on_meta ) {
						call_user_func( $on_meta, $payload );
					} elseif ( 'done' === $event ) {
						$done = $payload;
					} elseif ( 'error' === $event ) {
						$fail = $payload;
					}
				}

				return strlen( $chunk );
			},
		) );

		curl_exec( $ch );
		$errno = curl_errno( $ch );
		$error = curl_error( $ch );
		$code  = (int) curl_getinfo( $ch, CURLINFO_RESPONSE_CODE );
		curl_close( $ch );

		if ( 0 !== $errno ) {
			return new WP_Error( 'aipb_server_net', __( 'The Aura service cannot be reached: ', 'aipb' ) . $error, array( 'status' => 502 ) );
		}
		if ( is_array( $fail ) ) {
			$status = isset( $fail['status'] ) ? (int) $fail['status'] : 502;

			return new WP_Error( 'aipb_server', isset( $fail['error'] ) ? (string) $fail['error'] : __( 'Something went wrong during the generation.', 'aipb' ), array( 'status' => $status ) );
		}
		if ( 200 !== $code || ! is_array( $done ) || empty( $done['ok'] ) ) {
			// An error BEFORE the first piece still arrives as an ordinary JSON answer: the body
			// ended up in the events buffer, where there was nothing to read.
			$data = json_decode( $buffer, true );

			return self::server_error( $code ? $code : 502, is_array( $data ) ? $data : array() );
		}
		if ( ! isset( $done['raw'] ) || '' === trim( (string) $done['raw'] ) ) {
			return new WP_Error( 'aipb_server_empty', __( 'The Aura service returned no content at all.', 'aipb' ), array( 'status' => 502 ) );
		}

		return self::answer( $done );
	}

	/** The service's answer in the shape the rest of the plugin expects. */
	protected static function answer( $data ) {
		return array(
			'raw'        => (string) $data['raw'],
			'usage'      => isset( $data['usage'] ) && is_array( $data['usage'] ) ? $data['usage'] : array(),
			'model'      => isset( $data['model'] ) ? (string) $data['model'] : '',
			'warning'    => isset( $data['warning'] ) ? (string) $data['warning'] : '',
			// The service writes it on every line of its own log: it is what turns «a section came
			// out badly» into a case somebody can go and look at.
			'request_id' => isset( $data['request_id'] ) ? (string) $data['request_id'] : '',
			'ops'        => isset( $data['ops'] ) && is_array( $data['ops'] ) ? $data['ops'] : array(),
		);
	}

	protected static function server_error( $code, $data ) {
		$msg = isset( $data['error'] ) ? (string) $data['error'] : sprintf(
			/* translators: %d: HTTP status code. */
			__( 'The Aura service answered with an error (HTTP %d).', 'aipb' ),
			$code
		);

		// A 401 is not a network error and it is the only one the user can solve on their own: say
		// it as it is, instead of leaving them a generic message to interpret.
		if ( 401 === $code ) {
			$msg = __( 'Activation key invalid or revoked: check it in Aura → Settings.', 'aipb' );
		}
		// A 429 likewise: nothing is broken, it is only too much being asked at once.
		if ( 429 === $code ) {
			$msg = '' !== $msg ? $msg : __( 'Too many generations at once: try again shortly.', 'aipb' );
		}

		// The request's id, when there is one, goes at the end of the message: it is the only thing
		// the user can quote to support, and it opens the precise case on the other side.
		if ( ! empty( $data['request_id'] ) ) {
			$msg .= ' [' . (string) $data['request_id'] . ']';
		}

		return new WP_Error( 'aipb_server', $msg, array( 'status' => $code >= 400 ? $code : 502 ) );
	}

	/**
	 * The context, stripped of what the service does not need.
	 *
	 * It is worth being explicit about what leaves the site: what is sent is the code of the section
	 * being worked on and its contents, not the database. A closed list makes that verifiable — and
	 * stops a field added to the internal context tomorrow from going out without anyone deciding it.
	 */
	protected static function context( $context ) {
		$out = array();

		foreach ( array( 'target', 'template', 'viewport' ) as $k ) {
			if ( isset( $context[ $k ] ) && '' !== $context[ $k ] ) {
				$out[ $k ] = (string) $context[ $k ];
			}
		}
		if ( isset( $context['values'] ) && is_array( $context['values'] ) ) {
			$out['values'] = $context['values'];
		}
		if ( isset( $context['blocks'] ) && is_array( $context['blocks'] ) ) {
			// From the plan the service only needs the NAMES of the sections already there.
			$out['blocks'] = array_values( array_map(
				function ( $b ) {
					return array( 'name' => isset( $b['name'] ) ? (string) $b['name'] : __( 'Section', 'aipb' ) );
				},
				array_filter( $context['blocks'], 'is_array' )
			) );
		}
		if ( isset( $context['images'] ) ) {
			$out['images'] = (int) $context['images']; // how many screenshots, for the tokens prompt
		}
		if ( isset( $context['position'] ) && (int) $context['position'] > 0 ) {
			$out['position'] = (int) $context['position'];
		}
		if ( isset( $context['scope'] ) && 'page' === $context['scope'] ) {
			$out['scope'] = 'page';
		}
		if ( isset( $context['feedback'] ) && is_array( $context['feedback'] ) ) {
			$out['feedback'] = array_values( array_filter( array_map( 'strval', $context['feedback'] ) ) );
		}

		return $out;
	}
}
