<?php
/**
 * Costudio_Standalone_Crypto
 *
 * Specialized encryption for the standalone plugin.
 * Used for site credentials in Costudio_Standalone_Sites.
 *
 * Versioning:
 *  - FOSc1: AES-256-CBC (legacy, decrypt-only). Kept so existing records
 *           continue to work after the upgrade.
 *  - FOSc2: AES-256-GCM (current). Authenticated encryption with a 128-bit
 *           tag — tampered ciphertext fails to decrypt instead of returning
 *           a corrupted plaintext.
 *
 * Migration is lazy: encrypt() always writes FOSc2; decrypt() routes by
 * prefix and accepts both. Re-saving any record (e.g. editing a site
 * password) upgrades it to FOSc2 transparently.
 *
 * @package Contentosapp_Studio
 * @since   1.0.0
 */

defined( 'ABSPATH' ) || exit;

class Costudio_Standalone_Crypto {

	// Legacy — decrypt only.
	private const ALGO_V1   = 'AES-256-CBC';
	private const PREFIX_V1 = 'FOSc1:';

	// Current — authenticated.
	private const ALGO_V2   = 'AES-256-GCM';
	private const PREFIX_V2 = 'FOSc2:';
	private const IV_LEN_V2 = 12; // 96-bit IV — recommended for GCM.
	private const TAG_LEN   = 16; // 128-bit auth tag.

	/**
	 * Encrypt a plain-text string. Always writes the current format (FOSc2 / GCM).
	 *
	 * @param string $plain Plain text to encrypt.
	 * @return string Encrypted string with prefix, or the original input if
	 *                openssl is unavailable / encryption fails.
	 */
	public static function encrypt( string $plain ): string {
		if ( '' === $plain ) {
			return '';
		}

		if ( ! function_exists( 'openssl_encrypt' ) ) {
			return $plain;
		}

		$key = self::derive_key();
		$iv  = random_bytes( self::IV_LEN_V2 );
		$tag = '';

		$ciphertext = openssl_encrypt(
			$plain,
			self::ALGO_V2,
			$key,
			OPENSSL_RAW_DATA,
			$iv,
			$tag,
			'', // No additional authenticated data.
			self::TAG_LEN
		);

		if ( false === $ciphertext ) {
			return $plain;
		}

		$payload = base64_encode(
			wp_json_encode(
				array(
					'iv'  => base64_encode( $iv ),
					'ct'  => base64_encode( $ciphertext ),
					'tag' => base64_encode( $tag ),
				)
			)
		);

		return self::PREFIX_V2 . $payload;
	}

	/**
	 * Decrypt a string previously produced by encrypt().
	 *
	 * Routes by prefix:
	 *  - FOSc2: → AES-256-GCM (authenticated; rejects tampering)
	 *  - FOSc1: → AES-256-CBC (legacy compat)
	 *  - no prefix → returned as-is (pre-encryption installs)
	 *
	 * @param string $value Encrypted value.
	 * @return string Plain text, or the original value if decryption fails
	 *                (callers must treat both cases as opaque).
	 */
	public static function decrypt( string $value ): string {
		if ( '' === $value ) {
			return '';
		}

		if ( ! function_exists( 'openssl_decrypt' ) ) {
			return '';
		}

		if ( strpos( $value, self::PREFIX_V2 ) === 0 ) {
			return self::decrypt_v2( $value );
		}

		if ( strpos( $value, self::PREFIX_V1 ) === 0 ) {
			return self::decrypt_v1_legacy( $value );
		}

		return $value;
	}

	/**
	 * AES-256-GCM decrypt. Returns the original input string if the auth tag
	 * is invalid (tampering, wrong key, or truncated payload) — never returns
	 * corrupted plaintext.
	 */
	private static function decrypt_v2( string $value ): string {
		$json = base64_decode( substr( $value, strlen( self::PREFIX_V2 ) ) );
		$data = json_decode( (string) $json, true );

		if ( ! is_array( $data ) || ! isset( $data['iv'], $data['ct'], $data['tag'] ) ) {
			return $value;
		}

		$iv     = base64_decode( (string) $data['iv'] );
		$cipher = base64_decode( (string) $data['ct'] );
		$tag    = base64_decode( (string) $data['tag'] );

		foreach ( self::decryption_keys() as $key ) {
			$plain = openssl_decrypt(
				$cipher,
				self::ALGO_V2,
				$key,
				OPENSSL_RAW_DATA,
				$iv,
				$tag
			);

			if ( false !== $plain ) {
				return (string) $plain;
			}
		}

		return $value;
	}

	/**
	 * AES-256-CBC decrypt for FOSc1 records written before the GCM upgrade.
	 * Kept exclusively for backward compatibility — never written by encrypt().
	 */
	private static function decrypt_v1_legacy( string $value ): string {
		$json = base64_decode( substr( $value, strlen( self::PREFIX_V1 ) ) );
		$data = json_decode( (string) $json, true );

		if ( ! is_array( $data ) || ! isset( $data['iv'], $data['ct'] ) ) {
			return $value;
		}

		$iv     = base64_decode( (string) $data['iv'] );
		$cipher = base64_decode( (string) $data['ct'] );

		foreach ( self::decryption_keys() as $key ) {
			$plain = openssl_decrypt(
				$cipher,
				self::ALGO_V1,
				$key,
				OPENSSL_RAW_DATA,
				$iv
			);

			if ( false !== $plain ) {
				return (string) $plain;
			}
		}

		return $value;
	}

	/**
	 * Derive the current symmetric key.
	 *
	 * AUTH_KEY is still preferred so existing encrypted values remain
	 * decryptable. When AUTH_KEY is absent, new writes use a WordPress-managed
	 * per-install salt instead of the old universal hardcoded fallback.
	 */
	private static function derive_key(): string {
		$secret = function_exists( 'costudio_encryption_secret' )
			? costudio_encryption_secret()
			: ( defined( 'AUTH_KEY' ) && '' !== AUTH_KEY ? AUTH_KEY : wp_salt( 'auth' ) );

		return hash( 'sha256', $secret . 'costudio-sites-v1', true );
	}

	/**
	 * Return candidate keys for reading old records.
	 *
	 * The static fallback is decrypt-only and only tried on installs that do
	 * not define AUTH_KEY, matching the legacy misconfiguration path.
	 *
	 * @return string[]
	 */
	private static function decryption_keys(): array {
		$keys = array( self::derive_key() );

		if ( ! defined( 'AUTH_KEY' ) || '' === AUTH_KEY ) {
			$legacy = hash( 'sha256', 'costudio-standalone-fallback-key' . 'costudio-sites-v1', true );
			if ( $legacy !== $keys[0] ) {
				$keys[] = $legacy;
			}
		}

		return $keys;
	}

	/**
	 * HMAC signature for a JSON payload.
	 *
	 * @param string $json_body  Request body as JSON.
	 * @param string $secret_key License key (or shared secret).
	 * @return string Hex signature: "<timestamp>.<hash>"
	 */
	public static function sign_payload( string $json_body, string $secret_key ): string {
		$ts   = time();
		$hash = hash_hmac( 'sha256', $ts . '.' . $json_body, $secret_key );
		return $ts . '.' . $hash;
	}
}
