<?php
/**
 * Encryption of secrets at rest.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Support;

defined( 'ABSPATH' ) || exit;

/**
 * Authenticated encryption for the API token and webhook signing secret (spec §13).
 *
 * Uses `sodium_crypto_secretbox` (XSalsa20-Poly1305), so a tampered ciphertext
 * fails to open rather than decrypting to garbage.
 *
 * The key is derived from the site's own salts unless a dedicated
 * `TEXTTANGO_ENCRYPTION_KEY` constant is defined. That has one consequence worth
 * knowing: **rotating the WordPress salts makes existing secrets unreadable**.
 * Decryption therefore returns null and the admin is asked to re-enter their
 * credentials — it must never be a fatal error, and it must never silently look
 * like "no token configured".
 */
final class Crypto {

	/**
	 * Marks a value as produced by this class, and versions the format.
	 */
	private const PREFIX = 'tt1:';

	/**
	 * Domain-separation string for key derivation.
	 */
	private const KEY_CONTEXT = 'texttango:secretbox:v1:';

	/**
	 * Whether libsodium is usable on this host.
	 */
	public static function is_available(): bool {
		return function_exists( 'sodium_crypto_secretbox' )
			&& function_exists( 'sodium_crypto_secretbox_open' )
			&& function_exists( 'sodium_crypto_generichash' );
	}

	/**
	 * Whether the key is derived from WordPress salts rather than a dedicated constant.
	 *
	 * Surfaced in the UI so an admin understands why rotating salts would
	 * invalidate their stored credentials.
	 */
	public static function uses_derived_key(): bool {
		return ! defined( 'TEXTTANGO_ENCRYPTION_KEY' );
	}

	/**
	 * Encrypt a secret.
	 *
	 * @param string $plaintext Value to protect.
	 * @return string|null Ciphertext, or null when encryption is impossible.
	 */
	public static function encrypt( string $plaintext ): ?string {
		if ( '' === $plaintext || ! self::is_available() ) {
			return null;
		}

		$key = self::key();

		if ( null === $key ) {
			return null;
		}

		try {
			$nonce      = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
			$ciphertext = sodium_crypto_secretbox( $plaintext, $nonce, $key );
		} catch ( \Throwable $e ) {
			return null;
		} finally {
			sodium_memzero( $key );
		}

		return self::PREFIX . base64_encode( $nonce . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Binary-safe transport of ciphertext, not obfuscation.
	}

	/**
	 * Decrypt a secret.
	 *
	 * @param string $stored Value previously produced by {@see self::encrypt()}.
	 * @return string|null Plaintext, or null when the value is absent, malformed,
	 *                     or was encrypted under a key this site no longer has.
	 */
	public static function decrypt( string $stored ): ?string {
		if ( ! self::is_encrypted( $stored ) || ! self::is_available() ) {
			return null;
		}

		$decoded = base64_decode( substr( $stored, strlen( self::PREFIX ) ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Reverses self::encrypt().

		if ( false === $decoded || strlen( $decoded ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
			return null;
		}

		$key = self::key();

		if ( null === $key ) {
			return null;
		}

		try {
			$nonce      = substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
			$ciphertext = substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
			$plaintext  = sodium_crypto_secretbox_open( $ciphertext, $nonce, $key );
		} catch ( \Throwable $e ) {
			return null;
		} finally {
			sodium_memzero( $key );
		}

		return false === $plaintext ? null : $plaintext;
	}

	/**
	 * Whether a stored value looks like one of ours.
	 *
	 * @param string $value Candidate value.
	 */
	public static function is_encrypted( string $value ): bool {
		return str_starts_with( $value, self::PREFIX );
	}

	/**
	 * The 32-byte symmetric key.
	 *
	 * Callers must `sodium_memzero()` the returned value once finished with it.
	 */
	private static function key(): ?string {
		if ( defined( 'TEXTTANGO_ENCRYPTION_KEY' ) ) {
			$configured = (string) constant( 'TEXTTANGO_ENCRYPTION_KEY' );
			$decoded    = base64_decode( $configured, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Keys are configured base64-encoded.

			if ( is_string( $decoded ) && SODIUM_CRYPTO_SECRETBOX_KEYBYTES === strlen( $decoded ) ) {
				return $decoded;
			}

			// A constant that is present but unusable is a configuration error, and
			// falling back to salts would quietly encrypt under a different key.
			return null;
		}

		$material = self::KEY_CONTEXT
			. ( defined( 'AUTH_KEY' ) ? AUTH_KEY : '' )
			. ( defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : '' )
			. ( defined( 'LOGGED_IN_KEY' ) ? LOGGED_IN_KEY : '' );

		return sodium_crypto_generichash( $material, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
	}
}
