<?php
/**
 * AES-256-GCM encryption trait for PayPal credential storage.
 *
 * @since   1.0.0
 * @package DonationRouter
 */

declare( strict_types=1 );
namespace DonationRouter;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Provides AES-256-GCM encrypt/decrypt helpers for classes that handle
 * PayPal credentials.
 *
 * Payload format (binary, then base64-encoded):
 *   [ IV (12 bytes) ][ Auth tag (16 bytes) ][ Ciphertext (n bytes) ]
 *
 * Key derivation: HKDF-SHA256 over AUTH_KEY . SECURE_AUTH_SALT with the
 * plugin-specific info string "alamin-donation-router/v1".
 *
 * @since 1.0.0
 */
trait Encryption {

	/**
	 * Cipher algorithm.
	 *
	 * @since 1.0.0
	 *
	 * @var non-empty-string
	 */
	private static string $cipher = 'aes-256-gcm';

	/**
	 * IV length in bytes (96-bit nonce per NIST recommendation for GCM).
	 *
	 * @since 1.0.0
	 *
	 * @var positive-int
	 */
	private static int $iv_length = 12;

	/**
	 * GCM authentication tag length in bytes.
	 *
	 * @since 1.0.0
	 *
	 * @var positive-int
	 */
	private static int $tag_length = 16;

	/**
	 * HKDF info string scoped to this plugin.
	 *
	 * @since 1.0.0
	 *
	 * @var non-empty-string
	 */
	private static string $hkdf_info = 'alamin-donation-router/v1';

	/**
	 * Encrypt plaintext and return a base64-encoded payload.
	 *
	 * @since 1.0.0
	 *
	 * @param  string $plaintext  The secret value to encrypt.
	 *
	 * @return string  Base64-encoded IV + auth tag + ciphertext.
	 *
	 * @throws Credential_Decryption_Exception  On empty input or OpenSSL failure.
	 */
	protected function encrypt( string $plaintext ): string {
		if ( '' === $plaintext ) {
			throw new Credential_Decryption_Exception( 'Plaintext must not be empty.' );
		}

		$key = $this->derive_key();
		$iv  = random_bytes( self::$iv_length );
		$tag = '';

		$ciphertext = openssl_encrypt(
			$plaintext,
			self::$cipher,
			$key,
			OPENSSL_RAW_DATA,
			$iv,
			$tag,
			'',
			self::$tag_length
		);

		if ( false === $ciphertext ) {
			throw new Credential_Decryption_Exception( 'OpenSSL encryption failed.' );
		}

		return base64_encode( $iv . $tag . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
	}

	/**
	 * Decrypt a base64-encoded payload produced by {@see Encryption::encrypt()}.
	 *
	 * @since 1.0.0
	 *
	 * @param  string $payload  Base64-encoded IV + auth tag + ciphertext.
	 *
	 * @return string  The original plaintext.
	 *
	 * @throws Credential_Decryption_Exception  On empty input, malformed payload,
	 *                                          or auth-tag verification failure.
	 */
	protected function decrypt( string $payload ): string {
		if ( '' === $payload ) {
			throw new Credential_Decryption_Exception( 'Payload must not be empty.' );
		}

		$decoded = base64_decode( $payload, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode

		if ( false === $decoded || strlen( $decoded ) < ( self::$iv_length + self::$tag_length ) ) {
			throw new Credential_Decryption_Exception( 'Malformed or truncated payload.' );
		}

		$iv         = substr( $decoded, 0, self::$iv_length );
		$tag        = substr( $decoded, self::$iv_length, self::$tag_length );
		$ciphertext = substr( $decoded, self::$iv_length + self::$tag_length );

		$key       = $this->derive_key();
		$plaintext = openssl_decrypt(
			$ciphertext,
			self::$cipher,
			$key,
			OPENSSL_RAW_DATA,
			$iv,
			$tag
		);

		if ( false === $plaintext ) {
			throw new Credential_Decryption_Exception( 'Decryption failed: auth tag mismatch or corrupted data.' );
		}

		return $plaintext;
	}

	/**
	 * Encrypt a value destined for a wp_options entry, tolerating the empty
	 * string (an unset credential) without throwing.
	 *
	 * @since 1.1.0
	 *
	 * @param  string $plaintext  The value to store (may be empty).
	 *
	 * @return string  Encrypted payload, or '' when the input is empty.
	 */
	protected function encrypt_option_value( string $plaintext ): string {
		return '' === $plaintext ? '' : $this->encrypt( $plaintext );
	}

	/**
	 * Decrypt a value read from a wp_options entry.
	 *
	 * Returns '' for an unset option. A value that fails to decrypt is treated
	 * as legacy plaintext (written before 1.1.0 began encrypting these options)
	 * and returned as-is, so it transparently upgrades to ciphertext on the next
	 * save.
	 *
	 * @since 1.1.0
	 *
	 * @param  string $stored  The raw option value.
	 *
	 * @return string  Decrypted plaintext, '' when unset, or the raw value when
	 *                 it is legacy plaintext.
	 */
	protected function decrypt_option_value( string $stored ): string {
		if ( '' === $stored ) {
			return '';
		}

		try {
			return $this->decrypt( $stored );
		} catch ( Credential_Decryption_Exception $e ) {
			return $stored;
		}
	}

	/**
	 * Read and decrypt a wp_options value, optionally falling back to a second
	 * (legacy) option key when the primary is unset. Legacy plaintext values are
	 * tolerated via {@see Encryption::decrypt_option_value()}.
	 *
	 * @since 1.1.0
	 *
	 * @param  string $option           Primary option key.
	 * @param  string $fallback_option  Fallback option key, or '' for none.
	 *
	 * @return string  Decrypted value, or '' when neither option is set.
	 */
	protected function get_encrypted_option( string $option, string $fallback_option = '' ): string {
		$value = $this->decrypt_option_value( (string) get_option( $option, '' ) );

		if ( '' === $value && '' !== $fallback_option ) {
			$value = $this->decrypt_option_value( (string) get_option( $fallback_option, '' ) );
		}

		return $value;
	}

	/**
	 * Derive a 256-bit encryption key via HKDF-SHA256.
	 *
	 * Uses WordPress security constants as the input key material so that the
	 * derived key changes whenever WordPress salts are rotated.
	 *
	 * @since 1.0.0
	 *
	 * @return string  32-byte raw key.
	 */
	private function derive_key(): string {
		return hash_hkdf( 'sha256', AUTH_KEY . SECURE_AUTH_SALT, 32, self::$hkdf_info );
	}
}
