<?php

declare( strict_types=1 );

namespace Simplyin\Simplyin_W_Plugin\Tests\Unit;

use Brain\Monkey;
use Brain\Monkey\Functions;
use PHPUnit\Framework\TestCase;
use Simplyin\Simplyin_W_Plugin\Lib\Remote\Sin_Core_Api\Plugin_Rsa_Signer;

/**
 * RSA request-signing contract (plugin -> SIN.CORE). The canonical string and body
 * hash are the cross-repo contract — they must match the backend byte for byte
 * (same frozen vector).
 */
final class Plugin_Rsa_Signer_Test extends TestCase {

	protected function setUp(): void {
		parent::setUp();
		Monkey\setUp();
	}

	protected function tearDown(): void {
		Monkey\tearDown();
		parent::tearDown();
	}

	public function test_canonical_string_matches_golden_vector(): void {
		$signer    = new Plugin_Rsa_Signer();
		$canonical = $signer->build_canonical_string(
			'1750000000',
			'POST',
			'/api/payments/pay?foo=bar',
			'f60de7765374150d7ba85c88f7f196e18bed8a0b348efbfe42856452ad90988b',
			'nonce_abc'
		);

		$this->assertSame(
			"1750000000\nPOST\n/api/payments/pay?foo=bar\nf60de7765374150d7ba85c88f7f196e18bed8a0b348efbfe42856452ad90988b\nnonce_abc",
			$canonical
		);
	}

	public function test_method_is_uppercased(): void {
		$signer = new Plugin_Rsa_Signer();

		$this->assertStringContainsString(
			"\nPOST\n",
			$signer->build_canonical_string( '1', 'post', '/p', 'h', 'n' )
		);
	}

	public function test_body_hash_uses_empty_constant_and_sha256(): void {
		$signer = new Plugin_Rsa_Signer();

		$this->assertSame(
			'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
			$signer->body_sha256_hex( '' )
		);
		$this->assertSame(
			'f60de7765374150d7ba85c88f7f196e18bed8a0b348efbfe42856452ad90988b',
			$signer->body_sha256_hex( '{"merchantPaymentId":"pay_123","status":"PAID","amount":1999,"currency":"PLN"}' )
		);
	}

	public function test_build_headers_produces_a_verifiable_rsa_signature(): void {
		if ( ! function_exists( 'openssl_pkey_new' ) ) {
			$this->markTestSkipped( 'OpenSSL extension is required to exercise RSA signing.' );
		}

		$key = openssl_pkey_new(
			[
				'private_key_bits' => 2048,
				'private_key_type' => OPENSSL_KEYTYPE_RSA,
			]
		);
		openssl_pkey_export( $key, $private_pem );
		$public_pem = openssl_pkey_get_details( $key )['key'];

		Functions\when( 'get_option' )->alias(
			static function ( $name, $default = '' ) use ( $private_pem ) {
				switch ( $name ) {
					case Plugin_Rsa_Signer::OPTION_CREDENTIAL_ID:
						return 'cred_1';
					case Plugin_Rsa_Signer::OPTION_PRIVATE_KEY:
						return $private_pem;
					default:
						return $default;
				}
			}
		);

		$signer  = new Plugin_Rsa_Signer();
		$body    = '{"a":1}';
		$headers = $signer->build_headers( 'POST', '/api/payments/pay?foo=bar', $body );

		$this->assertSame( 'cred_1', $headers[ Plugin_Rsa_Signer::HEADER_CREDENTIAL_ID ] );
		$this->assertArrayHasKey( Plugin_Rsa_Signer::HEADER_SIGNATURE, $headers );

		// The backend verifies the signature over exactly this canonical string;
		// rebuild it from the emitted headers and confirm the public key verifies it.
		$canonical = implode(
			"\n",
			[
				$headers[ Plugin_Rsa_Signer::HEADER_TIMESTAMP ],
				'POST',
				'/api/payments/pay?foo=bar',
				hash( 'sha256', $body ),
				$headers[ Plugin_Rsa_Signer::HEADER_NONCE ],
			]
		);

		// Strict decode: a malformed signature header yields false, which would feed
		// openssl_verify() garbage and mask the real failure with a misleading result.
		$decoded_signature = base64_decode( $headers[ Plugin_Rsa_Signer::HEADER_SIGNATURE ], true );
		$this->assertNotFalse( $decoded_signature, 'Signature header must be valid base64.' );

		$verified = openssl_verify(
			$canonical,
			$decoded_signature,
			$public_pem,
			OPENSSL_ALGO_SHA256
		);

		$this->assertSame( 1, $verified );
	}

	public function test_sign_challenge_base64_rejects_invalid_base64(): void {
		// The failure branch logs via simplyin()->get_simplyin_logger(); stub the chain.
		Functions\when( 'simplyin' )->justReturn(
			new class() {
				public function get_simplyin_logger() {
					return new class() {
						public function log_if_wpdebug_on( $message, $context = '' ) {}
					};
				}
			}
		);

		$signer = new Plugin_Rsa_Signer();

		// Strict base64 decode fails on invalid input -> '' (no signing attempted),
		// so the caller never registers with a garbage signature.
		$this->assertSame( '', $signer->sign_challenge_base64( '@@@ not base64 @@@' ) );
	}

	public function test_sign_challenge_base64_signs_with_private_key(): void {
		if ( ! function_exists( 'openssl_pkey_new' ) ) {
			$this->markTestSkipped( 'OpenSSL extension is required to exercise RSA signing.' );
		}

		$key = openssl_pkey_new(
			[
				'private_key_bits' => 2048,
				'private_key_type' => OPENSSL_KEYTYPE_RSA,
			]
		);
		openssl_pkey_export( $key, $private_pem );
		$public_pem = openssl_pkey_get_details( $key )['key'];

		Functions\when( 'get_option' )->alias(
			static function ( $name, $default = '' ) use ( $private_pem ) {
				return Plugin_Rsa_Signer::OPTION_PRIVATE_KEY === $name ? $private_pem : $default;
			}
		);

		$signer    = new Plugin_Rsa_Signer();
		$challenge = 'challenge-bytes-123';
		$signature = $signer->sign_challenge_base64( base64_encode( $challenge ) );

		$decoded = base64_decode( $signature, true );
		$this->assertNotFalse( $decoded, 'Signature must be valid base64.' );
		$this->assertSame(
			1,
			openssl_verify( $challenge, $decoded, $public_pem, OPENSSL_ALGO_SHA256 )
		);
	}

	public function test_build_headers_returns_empty_without_credential_id(): void {
		// No credential id stored -> build_headers() must abort with [] so the caller
		// falls back to the legacy API key instead of sending half-signed requests.
		Functions\when( 'get_option' )->alias(
			static function ( $name, $default = '' ) {
				return $default;
			}
		);

		$signer = new Plugin_Rsa_Signer();

		$this->assertSame( [], $signer->build_headers( 'POST', '/api/payments/pay', '{"a":1}' ) );
	}

	public function test_generate_nonce_is_unique_32_char_hex(): void {
		$signer = new Plugin_Rsa_Signer();

		$first  = $signer->generate_nonce();
		$second = $signer->generate_nonce();

		// 16 random bytes -> 32 lowercase hex chars, within the backend-allowed
		// 16-128 / [A-Za-z0-9+/=_-] charset, and unique per request.
		$this->assertSame( 32, strlen( $first ) );
		$this->assertMatchesRegularExpression( '/^[0-9a-f]{32}$/', $first );
		$this->assertNotSame( $first, $second );
	}

	public function test_generate_key_pair_returns_unsaved_pair(): void {
		if ( ! function_exists( 'openssl_pkey_new' ) ) {
			$this->markTestSkipped( 'OpenSSL extension is required to exercise key generation.' );
		}

		// Staging: generation must NOT persist — register() stores only after the
		// backend confirms, so a failed completion never clobbers a working key.
		$persisted = false;
		Functions\when( 'update_option' )->alias(
			static function () use ( &$persisted ) {
				$persisted = true;

				return true;
			}
		);

		$pair = ( new Plugin_Rsa_Signer() )->generate_key_pair();

		$this->assertArrayHasKey( 'private_pem', $pair );
		$this->assertArrayHasKey( 'public_pem', $pair );
		$this->assertStringContainsString( 'PRIVATE KEY', $pair['private_pem'] );
		$this->assertStringContainsString( 'PUBLIC KEY', $pair['public_pem'] );
		$this->assertFalse( $persisted, 'generate_key_pair() must not persist the pair (staging).' );
	}

	public function test_note_rsa_response_412_clears_and_signals_legacy_retry(): void {
		$this->stub_logger();
		Functions\when( 'get_option' )->justReturn( '' );
		Functions\when( 'update_option' )->justReturn( true );
		$cleared = [];
		Functions\when( 'delete_option' )->alias(
			static function ( $name ) use ( &$cleared ) {
				$cleared[] = $name;

				return true;
			}
		);

		$this->assertTrue( ( new Plugin_Rsa_Signer() )->note_rsa_response( 412 ) );
		$this->assertContains( Plugin_Rsa_Signer::OPTION_PRIVATE_KEY, $cleared );
		$this->assertContains( Plugin_Rsa_Signer::OPTION_CREDENTIAL_ID, $cleared );
	}

	public function test_note_rsa_response_single_401_does_not_clear(): void {
		$this->stub_logger();
		Functions\when( 'get_option' )->justReturn( 0 ); // streak starts at 0
		Functions\when( 'delete_option' )->justReturn( true );
		$stored = null;
		Functions\when( 'update_option' )->alias(
			static function ( $name, $value ) use ( &$stored ) {
				$stored = [ $name, $value ];

				return true;
			}
		);

		$this->assertFalse( ( new Plugin_Rsa_Signer() )->note_rsa_response( 401 ) );
		$this->assertSame( [ Plugin_Rsa_Signer::OPTION_RSA_401_COUNT, 1 ], $stored );
	}

	public function test_note_rsa_response_401_streak_reaches_threshold_clears(): void {
		$this->stub_logger();
		Functions\when( 'get_option' )->alias(
			static function ( $name, $default = '' ) {
				// One short of the threshold already recorded -> this 401 crosses it.
				return Plugin_Rsa_Signer::OPTION_RSA_401_COUNT === $name
					? Plugin_Rsa_Signer::RSA_401_CLEAR_THRESHOLD - 1
					: $default;
			}
		);
		Functions\when( 'update_option' )->justReturn( true );
		$cleared = [];
		Functions\when( 'delete_option' )->alias(
			static function ( $name ) use ( &$cleared ) {
				$cleared[] = $name;

				return true;
			}
		);

		$this->assertTrue( ( new Plugin_Rsa_Signer() )->note_rsa_response( 401 ) );
		$this->assertContains( Plugin_Rsa_Signer::OPTION_PRIVATE_KEY, $cleared );
	}

	public function test_note_rsa_response_success_resets_streak(): void {
		Functions\when( 'get_option' )->justReturn( '2' ); // an existing non-empty streak
		Functions\when( 'update_option' )->justReturn( true );
		$cleared = [];
		Functions\when( 'delete_option' )->alias(
			static function ( $name ) use ( &$cleared ) {
				$cleared[] = $name;

				return true;
			}
		);

		$this->assertFalse( ( new Plugin_Rsa_Signer() )->note_rsa_response( 200 ) );
		$this->assertContains( Plugin_Rsa_Signer::OPTION_RSA_401_COUNT, $cleared );
	}

	private function stub_logger(): void {
		Functions\when( 'simplyin' )->justReturn(
			new class() {
				public function get_simplyin_logger() {
					return new class() {
						public function log_if_wpdebug_on( $message, $context = '' ) {}
					};
				}
			}
		);
	}
}
