<?php
/**
 * Symmetric encryption for secrets at rest (e.g. LLM API keys).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Core;

/**
 * Encrypts/decrypts strings with libsodium secretbox (XSalsa20-Poly1305).
 * The constructor takes raw key material directly, so this class has no
 * WordPress dependency and can be unit tested in isolation; use
 * {@see self::for_site()} to build an instance keyed off the site's salts.
 */
final class Crypto {

	private const HKDF_INFO = 'emberprice-encryption-v1';

	/**
	 * Derived secretbox key (SODIUM_CRYPTO_SECRETBOX_KEYBYTES long).
	 *
	 * @var string
	 */
	private string $key;

	/**
	 * Derives the secretbox key from the given key material via HKDF.
	 *
	 * @param string $key_material Secret input keying material (IKM); the
	 *                             actual encryption key is derived from it via HKDF.
	 */
	public function __construct( string $key_material ) {
		$this->key = hash_hkdf( 'sha256', $key_material, SODIUM_CRYPTO_SECRETBOX_KEYBYTES, self::HKDF_INFO );
	}

	/**
	 * Builds an instance keyed off this WordPress site's own salts.
	 */
	public static function for_site(): self {
		return new self( wp_salt( 'secure_auth' ) . wp_salt( 'auth' ) );
	}

	/**
	 * Encrypts a string. Output is base64-encoded (nonce prepended to the
	 * ciphertext) and safe to store as an option value.
	 *
	 * @param string $plaintext The value to encrypt.
	 */
	public function encrypt( string $plaintext ): string {
		$nonce      = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
		$ciphertext = sodium_crypto_secretbox( $plaintext, $nonce, $this->key );

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

	/**
	 * Decrypts a string produced by {@see self::encrypt()}.
	 *
	 * @param string $encoded The base64-encoded value produced by {@see self::encrypt()}.
	 *
	 * @throws \RuntimeException If the input is malformed, tampered with, or
	 *                           was encrypted with a different key.
	 */
	public function decrypt( string $encoded ): string {
		$raw = base64_decode( $encoded, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- transport encoding, not obfuscation.

		if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
			throw new \RuntimeException( 'Invalid ciphertext.' );
		}

		$nonce      = substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
		$ciphertext = substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
		$plaintext  = sodium_crypto_secretbox_open( $ciphertext, $nonce, $this->key );

		if ( false === $plaintext ) {
			throw new \RuntimeException( 'Decryption failed: tampered ciphertext or wrong key.' );
		}

		return $plaintext;
	}
}
