<?php
/**
 * Plugin RSA request signer.
 *
 * Implements the plugin side of SimplyIN's RSA-based request authentication.
 *
 * Outbound authenticated requests are signed with the shop's RSA private key
 * over a fixed canonical string; the backend verifies with the registered
 * public key plus anti-replay (timestamp + nonce). This class owns only the
 * key lifecycle and the signing contract — wiring into the HTTP client lives
 * in {@see Simplyin_Api_Legacy_Client}.
 *
 * @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 Simplyin\Simplyin_W_Plugin\Helper\Secret_Cipher;

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

class Plugin_Rsa_Signer {

	/**
	 * Runtime request auth headers (all four sent together or none).
	 */
	const HEADER_CREDENTIAL_ID = 'X-Auth-Merchant-Credential-Id';
	const HEADER_SIGNATURE     = 'X-Auth-Merchant-Signature';
	const HEADER_TIMESTAMP     = 'X-Auth-Merchant-Timestamp';
	const HEADER_NONCE         = 'X-Auth-Merchant-Nonce';

	/**
	 * SHA-256 of an empty body (GET/HEAD or no body). Must match what the
	 * backend expects exactly.
	 */
	const EMPTY_BODY_SHA256_HEX = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';

	/**
	 * WordPress option keys for the key material and pairing identifiers.
	 */
	const OPTION_PRIVATE_KEY   = 'simplyin_plugin_rsa_private_key';
	const OPTION_PUBLIC_KEY    = 'simplyin_plugin_rsa_public_key';
	const OPTION_CREDENTIAL_ID = 'simplyin_plugin_credential_id';
	const OPTION_DISABLED      = 'simplyin_plugin_rsa_disabled';

	/** Consecutive RSA-401 counter for the rotate/revoke resilience guard. */
	const OPTION_RSA_401_COUNT = 'simplyin_plugin_rsa_401_count';

	/** Clear local RSA + fall back after this many consecutive RSA 401s (transient-tolerant). */
	const RSA_401_CLEAR_THRESHOLD = 3;

	/**
	 * Whether RSA-signed requests should be used for authenticated traffic.
	 *
	 * True only when the shop has a registered key pair AND a credential id,
	 * and the kill switch is not engaged. While false the caller must keep
	 * using the legacy API key path (matches backend "4/4 headers or none").
	 */
	public function is_active(): bool {
		// Require OpenSSL: without it sign_bytes() always fails, so RSA mode would send requests
		// with no usable auth. Staying inactive keeps the shop on legacy API-key auth, which does
		// not need OpenSSL, on hosts where the extension is missing/disabled.
		return $this->is_registered()
			&& ! $this->is_disabled()
			&& function_exists( 'openssl_sign' );
	}

	/**
	 * Whether the local RSA kill switch is engaged (durable disable). While true,
	 * RSA is not used and auto-registration must not re-enable it.
	 */
	public function is_disabled(): bool {
		return '1' === (string) get_option( self::OPTION_DISABLED );
	}

	/**
	 * React to the HTTP status of an RSA-signed response and decide whether the caller
	 * should drop RSA and retry over the legacy API key. Key off the status CODE, never
	 * the i18n message.
	 *
	 * - 412 PRECONDITION_FAILED = credential exists but no RSA key registered (post
	 *   revoke/rotate) → clear local RSA immediately and signal a legacy retry.
	 * - 401 = a transient RSA rejection (bad signature / clock skew / replay / origin) →
	 *   clear only after RSA_401_CLEAR_THRESHOLD consecutive 401s.
	 * - anything else (including 2xx) resets the streak.
	 *
	 * @param int $status HTTP status of the RSA-signed response.
	 *
	 * @return bool True when RSA was cleared and the caller should retry via the legacy API key.
	 */
	public function note_rsa_response( int $status ): bool {
		if ( 412 === $status ) {
			$this->log( 'note_rsa_response: 412 — RSA key not registered on backend; clearing local RSA and falling back' );
			$this->clear();

			return true;
		}

		if ( 401 === $status ) {
			$count = (int) get_option( self::OPTION_RSA_401_COUNT, 0 ) + 1;
			if ( $count >= self::RSA_401_CLEAR_THRESHOLD ) {
				$this->log( sprintf( 'note_rsa_response: %d consecutive RSA 401s; clearing local RSA and falling back', $count ) );
				$this->clear();

				return true;
			}
			update_option( self::OPTION_RSA_401_COUNT, $count, false );

			return false;
		}

		// Success or an unrelated status — reset the 401 streak.
		if ( '' !== (string) get_option( self::OPTION_RSA_401_COUNT, '' ) ) {
			delete_option( self::OPTION_RSA_401_COUNT );
		}

		return false;
	}

	/**
	 * Whether a private key and credential id are both stored locally.
	 */
	public function is_registered(): bool {
		return '' !== $this->get_private_key_pem() && '' !== $this->get_credential_id();
	}

	/**
	 * Generate a fresh 2048-bit RSA key pair WITHOUT persisting it.
	 *
	 * Registration persists the pair only after the backend confirms (see
	 * store_key_pair), so a failed completion never clobbers a working key.
	 *
	 * @return array{private_pem:string,public_pem:string} Empty array on failure.
	 */
	public function generate_key_pair(): array {
		if ( ! function_exists( 'openssl_pkey_new' ) || ! function_exists( 'openssl_pkey_export' ) ) {
			$this->log( 'generate_key_pair: OpenSSL extension unavailable' );

			return [];
		}

		$resource = openssl_pkey_new(
			[
				'private_key_bits' => 2048,
				'private_key_type' => OPENSSL_KEYTYPE_RSA,
			]
		);

		if ( false === $resource ) {
			$this->log( 'generate_key_pair: openssl_pkey_new failed' );

			return [];
		}

		$private_pem = '';
		if ( false === openssl_pkey_export( $resource, $private_pem ) ) {
			$this->log( 'generate_key_pair: openssl_pkey_export failed' );

			return [];
		}

		$details = openssl_pkey_get_details( $resource );
		if ( false === $details || empty( $details['key'] ) ) {
			$this->log( 'generate_key_pair: openssl_pkey_get_details failed' );

			return [];
		}

		return [
			'private_pem' => $private_pem,
			'public_pem'  => (string) $details['key'],
		];
	}

	/**
	 * Persist a key pair as the active RSA material — private key encrypted at rest
	 * and without autoload; public key is not secret. Call only after the backend has accepted
	 * the public key (staging), so a failed registration can never overwrite a working key.
	 */
	public function store_key_pair( string $private_pem, string $public_pem ): void {
		update_option( self::OPTION_PRIVATE_KEY, Secret_Cipher::encrypt( $private_pem ), false );
		update_option( self::OPTION_PUBLIC_KEY, $public_pem, false );
	}

	/**
	 * Sign the base64-encoded registration challenge to prove private key
	 * possession during the one-time registration completion.
	 *
	 * @param string $challenge_base64 Challenge from startPluginPublicKeyRegistration.
	 *
	 * @return string Base64 RSA-SHA256 signature, or '' on failure.
	 */
	public function sign_challenge_base64( string $challenge_base64 ): string {
		$challenge_bytes = base64_decode( $challenge_base64, true );
		if ( false === $challenge_bytes ) {
			$this->log( 'sign_challenge_base64: invalid base64 challenge' );

			return '';
		}

		return $this->sign_bytes( $challenge_bytes );
	}

	/**
	 * Sign a base64 challenge with a SPECIFIC private key. Used during staged registration to
	 * prove possession of the freshly generated key before it is persisted.
	 *
	 * @param string $challenge_base64 Challenge from startPluginPublicKeyRegistration.
	 * @param string $private_pem      Private key PEM to sign with.
	 *
	 * @return string Base64 RSA-SHA256 signature, or '' on failure.
	 */
	public function sign_challenge_base64_with_key( string $challenge_base64, string $private_pem ): string {
		$challenge_bytes = base64_decode( $challenge_base64, true );
		if ( false === $challenge_bytes ) {
			$this->log( 'sign_challenge_base64: invalid base64 challenge' );

			return '';
		}

		return $this->sign_bytes_with( $challenge_bytes, $private_pem );
	}

	/**
	 * Build the four runtime auth headers for a signed request.
	 *
	 * @param string $method          HTTP method.
	 * @param string $path_with_query Request path + query, exactly as transmitted.
	 * @param string $raw_body        Raw request body bytes ('' for no body).
	 *
	 * @return array<string,string> Headers, or [] when signing was not possible.
	 */
	public function build_headers(
		string $method,
		string $path_with_query,
		string $raw_body
	): array {
		$credential_id = $this->get_credential_id();
		if ( '' === $credential_id ) {
			return [];
		}

		$timestamp_sec = (string) time();
		$nonce         = $this->generate_nonce();
		if ( '' === $nonce ) {
			// Nonce generation failed (CSPRNG unavailable). Abort signing so the
			// caller can fall back instead of sending an unsigned/half-signed request.
			return [];
		}
		$body_sha256hex = $this->body_sha256_hex( $raw_body );
		$canonical      = $this->build_canonical_string(
			$timestamp_sec,
			$method,
			$path_with_query,
			$body_sha256hex,
			$nonce
		);

		$signature = $this->sign_bytes( $canonical );
		if ( '' === $signature ) {
			return [];
		}

		return [
			self::HEADER_CREDENTIAL_ID => $credential_id,
			self::HEADER_SIGNATURE     => $signature,
			self::HEADER_TIMESTAMP     => $timestamp_sec,
			self::HEADER_NONCE         => $nonce,
		];
	}

	/**
	 * Canonical string to sign. Fixed five-line format, LF separated — must
	 * match the backend's expected format byte for byte.
	 */
	public function build_canonical_string(
		string $timestamp_sec,
		string $method,
		string $path_with_query,
		string $body_sha256hex,
		string $nonce
	): string {
		return implode(
			"\n",
			[
				$timestamp_sec,
				strtoupper( $method ),
				$path_with_query,
				$body_sha256hex,
				$nonce,
			]
		);
	}

	/**
	 * SHA-256 hex of the raw body bytes; empty-body constant for no body.
	 */
	public function body_sha256_hex( string $raw_body ): string {
		if ( '' === $raw_body ) {
			return self::EMPTY_BODY_SHA256_HEX;
		}

		return hash( 'sha256', $raw_body );
	}

	/**
	 * Unique per-request nonce (32 hex chars — within the backend-allowed
	 * 16–128 / [A-Za-z0-9+/=_-] charset).
	 *
	 * random_bytes() can throw if the OS CSPRNG is unavailable; catch it and
	 * return '' so callers abort gracefully rather than fataling the request.
	 *
	 * @return string Nonce hex, or '' when secure randomness is unavailable.
	 */
	public function generate_nonce(): string {
		try {
			return bin2hex( random_bytes( 16 ) );
		} catch ( \Throwable $e ) {
			$this->log( 'generate_nonce: random_bytes failed — ' . $e->getMessage() );

			return '';
		}
	}

	public function get_private_key_pem(): string {
		return Secret_Cipher::decrypt( (string) get_option( self::OPTION_PRIVATE_KEY, '' ) );
	}

	public function get_public_key_pem(): string {
		return (string) get_option( self::OPTION_PUBLIC_KEY, '' );
	}

	public function get_credential_id(): string {
		return (string) get_option( self::OPTION_CREDENTIAL_ID, '' );
	}

	public function set_credential_id( string $credential_id ): void {
		update_option( self::OPTION_CREDENTIAL_ID, $credential_id, false );
	}

	/**
	 * Release the kill switch — a successful (re-)registration re-enables RSA.
	 */
	public function clear_disabled(): void {
		delete_option( self::OPTION_DISABLED );
	}

	/**
	 * Drop all local key material and pairing identifiers (rotation / revoke).
	 */
	public function clear(): void {
		delete_option( self::OPTION_PRIVATE_KEY );
		delete_option( self::OPTION_PUBLIC_KEY );
		delete_option( self::OPTION_CREDENTIAL_ID );
		// Also clear the kill switch: after a rotation/revoke + fresh registration RSA must
		// become active again. Leaving simplyin_plugin_rsa_disabled set would make is_active()
		// return false and silently keep the re-registered key inert.
		delete_option( self::OPTION_DISABLED );
		// Reset the consecutive-401 streak so a fresh registration starts clean.
		delete_option( self::OPTION_RSA_401_COUNT );
	}

	/**
	 * RSA-SHA256 sign arbitrary bytes with the stored private key.
	 *
	 * @return string Base64 signature, or '' on failure.
	 */
	private function sign_bytes( string $message ): string {
		return $this->sign_bytes_with( $message, $this->get_private_key_pem() );
	}

	/**
	 * RSA-SHA256 sign arbitrary bytes with a given private key.
	 *
	 * @return string Base64 signature, or '' on failure.
	 */
	private function sign_bytes_with( string $message, string $private_pem ): string {
		if ( '' === $private_pem ) {
			$this->log( 'sign_bytes: no private key' );

			return '';
		}

		if ( ! function_exists( 'openssl_sign' ) ) {
			$this->log( 'sign_bytes: OpenSSL extension unavailable' );

			return '';
		}

		$signature = '';
		$ok        = openssl_sign( $message, $signature, $private_pem, OPENSSL_ALGO_SHA256 );
		if ( false === $ok ) {
			$this->log( 'sign_bytes: openssl_sign failed' );

			return '';
		}

		return base64_encode( $signature );
	}

	/**
	 * Best-effort debug log. Swallows a missing plugin bootstrap (`simplyin()`) rather than
	 * throwing: signing methods here run from `uninstall.php` (via Plugin_Rsa_Registrar::revoke()),
	 * which WordPress executes standalone without the plugin's main file, so `simplyin()` may
	 * not be defined there.
	 */
	private function log( string $message ): void {
		try {
			simplyin()
				->get_simplyin_logger()
				->log_if_wpdebug_on( '[Plugin_Rsa_Signer] ' . $message, 'api-call' );
		} catch ( \Throwable $e ) {
			// No logger available outside normal plugin execution — nothing to do.
		}
	}
}
