<?php
/**
 * Guzzle client factory for the generated SimplyInApi (PaymentsApi).
 *
 * The generated payment client authenticates with the legacy
 * X-Auth-Merchant-Api-Key header (set on its Configuration). When RSA request
 * signing is active this factory transparently swaps that header for the four
 * X-Auth-Merchant-* RSA headers, computed over the *actual* outgoing request
 * (method, path?query, body) so the canonical string matches the bytes the
 * backend receives. When RSA is not active the request passes through
 * unchanged, so legacy API key auth keeps working.
 *
 * Mirrors the RSA path in Simplyin_Api_Legacy_Client::sendRequest so both HTTP
 * clients sign identically.
 *
 * @package Simplyin\Simplyin_W_Plugin\Lib\Remote\Sin_Core_Api
 */

declare( strict_types=1 );

namespace Simplyin\Simplyin_W_Plugin\Lib\Remote\Sin_Core_Api;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Promise\Create;
use Psr\Http\Message\RequestInterface;

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

class Plugin_Rsa_Guzzle_Client {

	/**
	 * Build a Guzzle client whose outgoing requests are RSA-signed when active.
	 */
	public static function create(): Client {
		$stack = HandlerStack::create();
		$stack->push( Middleware::mapRequest( [ self::class, 'sign_request' ] ) );
		// Outer of the signer: reacts to the response for rotate/revoke resilience.
		$stack->push( self::rsa_retry_middleware() );

		return new Client( [ 'handler' => $stack ] );
	}

	/**
	 * Response middleware for rotate/revoke resilience: when an RSA-signed
	 * PaymentsApi call returns 412 (RSA key not registered on the backend, i.e. post revoke/rotate)
	 * or crosses the consecutive-401 threshold, clear local RSA and retry the same call once over
	 * the legacy API key (dual-auth), so a revoke/rotate never fails a live payment. Handles both
	 * fulfilled responses and rejected promises (http_errors on/off).
	 */
	public static function rsa_retry_middleware(): callable {
		return static function ( callable $handler ): callable {
			return static function ( RequestInterface $request, array $options ) use ( $handler ) {
				// The signer runs inner (after this middleware), so decide up front whether this
				// request will be RSA-signed; is_active() is stable within a single request.
				$rsa_active = ( new Plugin_Rsa_Signer() )->is_active();

				// Snapshot the body once and hand a fresh stream downstream, so a legacy retry can
				// re-send the exact bytes even though sign_request() consumes the stream (casts it
				// to string) — robust regardless of stream seekability.
				$raw_body = (string) $request->getBody();
				$request  = $request->withBody( \GuzzleHttp\Psr7\Utils::streamFor( $raw_body ) );

				$retry = static function ( int $status ) use ( $handler, $request, $options, $rsa_active, $raw_body ) {
					if ( ! $rsa_active ) {
						return null;
					}
					// note_rsa_response() clears RSA on 412 / repeated 401 (side effect kept even
					// when we don't retry, so the scheduler recovers).
					if ( ! ( new Plugin_Rsa_Signer() )->note_rsa_response( $status ) ) {
						return null;
					}
					// No legacy API key configured (allowed once on RSA) → don't replay a doomed
					// unauthenticated call that would hide the original failure; surface it instead.
					if ( '' === (string) get_option( 'simplyin_api_key', '' ) ) {
						return null;
					}

					// RSA cleared — retry once over the legacy API key.
					return $handler( self::to_legacy_request( $request, $raw_body ), $options );
				};

				return $handler( $request, $options )->then(
					static function ( $response ) use ( $retry ) {
						$retried = $retry( $response->getStatusCode() );

						return null !== $retried ? $retried : $response;
					},
					static function ( $reason ) use ( $retry ) {
						$status = ( $reason instanceof RequestException && $reason->hasResponse() )
							? $reason->getResponse()->getStatusCode()
							: 0;

						$retried = $retry( $status );

						return null !== $retried ? $retried : Create::rejectionFor( $reason );
					}
				);
			};
		};
	}

	/**
	 * Rebuild an outgoing request for a legacy API-key retry: drop the four RSA headers, add
	 * X-Auth-Merchant-Api-Key, and restore the body from the snapshot (the first attempt
	 * consumed the original stream).
	 */
	private static function to_legacy_request( RequestInterface $request, string $raw_body ): RequestInterface {
		foreach (
			[
				Plugin_Rsa_Signer::HEADER_CREDENTIAL_ID,
				Plugin_Rsa_Signer::HEADER_SIGNATURE,
				Plugin_Rsa_Signer::HEADER_TIMESTAMP,
				Plugin_Rsa_Signer::HEADER_NONCE,
			] as $header
		) {
			$request = $request->withoutHeader( $header );
		}

		$api_key = (string) get_option( 'simplyin_api_key', '' );
		if ( '' !== $api_key ) {
			$request = $request->withHeader( 'X-Auth-Merchant-Api-Key', $api_key );
		}

		return $request->withBody( \GuzzleHttp\Psr7\Utils::streamFor( $raw_body ) );
	}

	/**
	 * Middleware: when RSA is active, replace the merchant API key header with
	 * the four RSA headers signed over this exact request. No-op otherwise.
	 */
	public static function sign_request( RequestInterface $request ): RequestInterface {
		$signer = new Plugin_Rsa_Signer();
		if ( ! $signer->is_active() ) {
			return $request; // legacy API key auth stays untouched
		}

		$uri             = $request->getUri();
		$query           = $uri->getQuery();
		$path_with_query = $uri->getPath() . ( '' !== $query ? '?' . $query : '' );

		// Snapshot the body bytes; casting a stream to string leaves the pointer
		// at EOF. Rewinding can throw on non-seekable PSR-7 streams, so instead of
		// rewind() we replace the body with a fresh in-memory stream of the same
		// bytes — safe regardless of seekability. Do this BEFORE build_headers so
		// EVERY return path (incl. the empty-headers fallback) carries the original
		// body; otherwise the fallback request would ship the consumed (empty) stream.
		$raw_body = (string) $request->getBody();
		$request  = $request->withBody( \GuzzleHttp\Psr7\Utils::streamFor( $raw_body ) );

		$headers = $signer->build_headers( $request->getMethod(), $path_with_query, $raw_body );
		if ( empty( $headers ) ) {
			// RSA is active but signing was unavailable (transient nonce/openssl failure).
			// Fail closed: strip the legacy key so the backend rejects the request instead
			// of silently downgrading to API-key auth. Mirrors Simplyin_Api_Legacy_Client,
			// which sends no auth header in this case.
			return $request->withoutHeader( 'X-Auth-Merchant-Api-Key' );
		}

		// RSA and the legacy key are mutually exclusive (backend accepts all
		// four RSA headers or none), so drop the key when signing.
		$request = $request->withoutHeader( 'X-Auth-Merchant-Api-Key' );
		foreach ( $headers as $name => $value ) {
			$request = $request->withHeader( $name, $value );
		}

		return $request;
	}
}
