<?php

declare(strict_types=1);
/**
 * SecretManager - Sodium-based secret encryption service.
 *
 * Provides secure storage and retrieval of sensitive data in WordPress options.
 * Derives its encryption key from the shared MasterKey via HKDF.
 *
 */

namespace SiteFort\Includes;

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

/**
 * Class SecretManager
 *
 * Encryption/decryption for sensitive data.
 * Sodium crypto_secretbox (XSalsa20-Poly1305) with OpenSSL AES-256-GCM fallback.
 */
class SecretManager {

	private const ENVELOPE_SODIUM       = 'v1:';
	private const ENVELOPE_OPENSSL      = 'v2:';
	private const ENVELOPE_SODIUM_AEAD  = 'v3:';
	private const ENVELOPE_OPENSSL_AEAD = 'v4:';

	private const OPENSSL_IV_BYTES  = 12;
	private const OPENSSL_TAG_BYTES = 16;

	private const KEY_BYTES = 32;
	private const HKDF_INFO = 'sitefort-secretbox-v3';

	const OPTION_PREFIX = 'sitefort_encrypted_';

	/** @var array<string,mixed>|null Request-scoped key-health issue detected while decrypting stored secrets. */
	private static ?array $runtime_key_issue = null;

	/** @var bool Guard to avoid scanning encrypted options more than once per request. */
	private static bool $store_key_issue_checked = false;


	/**
	 * Encrypt a plaintext string for secure storage.
	 *
	 * @param string $plaintext The secret to encrypt.
	 * @param string $context   Associated data bound to the ciphertext, so a blob
	 *                          cannot be relocated to a different storage slot.
	 * @return string Envelope-prefixed, base64-encoded ciphertext.
	 * @throws \RuntimeException If encryption fails.
	 */
	public static function encrypt( string $plaintext, string $context = '' ): string {
		if ( self::is_sodium_aead_available() ) {
			$key   = self::get_encryption_key();
			$nonce = random_bytes( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES );

			try {
				$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( $plaintext, $context, $nonce, $key );
			} finally {
				self::memzero( $key );
			}

			return self::ENVELOPE_SODIUM_AEAD . base64_encode( $nonce . $ciphertext );
		}

		if ( self::is_openssl_available() ) {
			$key = self::get_encryption_key();
			$iv  = random_bytes( self::OPENSSL_IV_BYTES );
			$tag = '';

			try {
				$ciphertext = openssl_encrypt( $plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, $context );
			} finally {
				self::memzero( $key );
			}

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

			return self::ENVELOPE_OPENSSL_AEAD . base64_encode( $iv . $tag . $ciphertext );
		}

		if ( self::is_sodium_available() ) {
			$key   = self::get_encryption_key();
			$nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );

			try {
				$ciphertext = sodium_crypto_secretbox( $plaintext, $nonce, $key );
			} finally {
				self::memzero( $key );
			}

			return self::ENVELOPE_SODIUM . base64_encode( $nonce . $ciphertext );
		}

		throw new \RuntimeException( 'No supported encryption provider available.' );
	}

	/**
	 * Decrypt an encrypted string.
	 *
	 * @param string $encrypted Envelope-prefixed, base64-encoded ciphertext.
	 * @return string|false Decrypted plaintext, or false on failure.
	 */
	public static function decrypt( string $encrypted, string $context = '' ) {
		try {
			if ( strncmp( $encrypted, self::ENVELOPE_SODIUM_AEAD, 3 ) === 0 ) {
				return self::decrypt_sodium_aead( substr( $encrypted, 3 ), $context );
			}

			if ( strncmp( $encrypted, self::ENVELOPE_OPENSSL_AEAD, 3 ) === 0 ) {
				return self::decrypt_openssl( substr( $encrypted, 3 ), $context );
			}

			if ( strncmp( $encrypted, self::ENVELOPE_OPENSSL, 3 ) === 0 ) {
				return self::decrypt_openssl( substr( $encrypted, 3 ), '' );
			}

			$payload = strncmp( $encrypted, self::ENVELOPE_SODIUM, 3 ) === 0
				? substr( $encrypted, 3 )
				: $encrypted;

			return self::decrypt_sodium_secretbox( $payload );
		} catch ( \SodiumException $e ) {
			// Message may leak key/ciphertext format details, so log a generic line.
			sitefort_log( '[SiteFort SecretManager] Decryption failed (authentication/key error).' );
			return false;
		} catch ( \RuntimeException $e ) {
			sitefort_log( '[SiteFort SecretManager] Decryption failed: ' . $e->getMessage() );
			return false;
		}
	}

	/**
	 * @return string|false
	 */
	private static function decrypt_sodium_aead( string $b64, string $aad ) {
		if ( ! self::is_sodium_aead_available() ) {
			return false;
		}

		$decoded = base64_decode( $b64, true );
		$nonce_len = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES;
		if ( false === $decoded || strlen( $decoded ) <= $nonce_len ) {
			return false;
		}

		$nonce      = substr( $decoded, 0, $nonce_len );
		$ciphertext = substr( $decoded, $nonce_len );
		$key        = self::get_encryption_key();

		try {
			return sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( $ciphertext, $aad, $nonce, $key );
		} finally {
			self::memzero( $key );
		}
	}

	/**
	 * Handles both v2 (no associated data) and v4 (associated data) GCM blobs.
	 *
	 * @return string|false
	 */
	private static function decrypt_openssl( string $b64, string $aad ) {
		if ( ! self::is_openssl_available() ) {
			return false;
		}

		$decoded = base64_decode( $b64, true );
		$min_len = self::OPENSSL_IV_BYTES + self::OPENSSL_TAG_BYTES + 1;
		if ( false === $decoded || strlen( $decoded ) < $min_len ) {
			return false;
		}

		$iv         = substr( $decoded, 0, self::OPENSSL_IV_BYTES );
		$tag        = substr( $decoded, self::OPENSSL_IV_BYTES, self::OPENSSL_TAG_BYTES );
		$ciphertext = substr( $decoded, self::OPENSSL_IV_BYTES + self::OPENSSL_TAG_BYTES );
		$key        = self::get_encryption_key();

		try {
			return openssl_decrypt( $ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, $aad );
		} finally {
			self::memzero( $key );
		}
	}

	/**
	 * @return string|false
	 */
	private static function decrypt_sodium_secretbox( string $b64 ) {
		if ( ! self::is_sodium_available() ) {
			return false;
		}

		$decoded = base64_decode( $b64, true );
		$min_len = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES;
		if ( false === $decoded || strlen( $decoded ) < $min_len ) {
			return false;
		}

		$nonce      = substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
		$ciphertext = substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
		$key        = self::get_encryption_key();

		try {
			return sodium_crypto_secretbox_open( $ciphertext, $nonce, $key );
		} finally {
			self::memzero( $key );
		}
	}

	/**
	 * Store an encrypted secret in wp_options.
	 *
	 * @param string $name      Secret name (will be prefixed).
	 * @param string $plaintext Secret value.
	 * @return bool True on success.
	 */
	public static function set( string $name, string $plaintext ): bool {
		try {
			$encrypted = self::encrypt( $plaintext, self::OPTION_PREFIX . $name );
			return update_option( self::OPTION_PREFIX . $name, $encrypted, false );
		} catch ( \SodiumException $e ) {
			sitefort_log( '[SiteFort SecretManager] Failed to store secret (encryption error).' );
			return false;
		} catch ( \RuntimeException $e ) {
			sitefort_log( '[SiteFort SecretManager] Failed to store secret: ' . $e->getMessage() );
			return false;
		}
	}

	/**
	 * Retrieve and decrypt a secret from wp_options.
	 *
	 * @param string $name Secret name (without prefix).
	 * @return string|false Decrypted secret, or false if not found/invalid.
	 */
	public static function get( string $name ) {
		$encrypted = get_option( self::OPTION_PREFIX . $name, '' );

		if ( empty( $encrypted ) ) {
			return false;
		}

		$decrypted = self::decrypt( $encrypted, self::OPTION_PREFIX . $name );
		if ( false === $decrypted ) {
			self::record_runtime_key_issue( $name );
		}

		return $decrypted;
	}

	/**
	 * Return the last request-scoped key mismatch detected while decrypting secrets.
	 *
	 * @return array<string,mixed>|null
	 */
	public static function get_runtime_key_issue(): ?array {
		return self::$runtime_key_issue;
	}

	/**
	 * Scan stored encrypted SiteFort secrets and surface a request-level key mismatch.
	 *
	 * This covers the case where the master key file loads successfully, but one or
	 * more encrypted secrets were written with a different historical key.
	 *
	 * @return array<string,mixed>|null
	 */
	public static function detect_store_key_issue(): ?array {
		if ( self::$store_key_issue_checked ) {
			return self::$runtime_key_issue;
		}

		self::$store_key_issue_checked = true;

		try {
			MasterKey::get();
		} catch ( \RuntimeException $e ) {
			return MasterKey::get_last_error();
		}

		global $wpdb;
		if ( ! isset( $wpdb->options ) ) {
			return self::$runtime_key_issue;
		}

		$like = self::OPTION_PREFIX . '%';
		$table = $wpdb->options;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Scans encrypted options once per request to detect master-key mismatch without caching stale key-health state.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT option_name, option_value FROM {$table} WHERE option_name LIKE %s",
				$like
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( ! is_array( $rows ) ) {
			return self::$runtime_key_issue;
		}

		foreach ( $rows as $row ) {
			$option_name = isset( $row['option_name'] ) ? (string) $row['option_name'] : '';
			$encrypted   = isset( $row['option_value'] ) ? (string) $row['option_value'] : '';
			if ( $option_name === '' || $encrypted === '' ) {
				continue;
			}

			if ( false === self::decrypt( $encrypted, $option_name ) ) {
				$secret_name = str_starts_with( $option_name, self::OPTION_PREFIX )
					? substr( $option_name, strlen( self::OPTION_PREFIX ) )
					: $option_name;
				self::record_runtime_key_issue( $secret_name );
				break;
			}
		}

		return self::$runtime_key_issue;
	}

	/**
	 * Delete a stored secret.
	 *
	 * @param string $name Secret name.
	 * @return bool True if deleted.
	 */
	public static function delete( string $name ): bool {
		return delete_option( self::OPTION_PREFIX . $name );
	}

	/**
	 * Delete all encrypted SiteFort options through the Options API.
	 *
	 * Deleting through delete_option() ensures object-cache invalidation on sites
	 * with a persistent cache backend instead of leaving stale option values in
	 * memory after a direct SQL delete.
	 *
	 * @return int Number of option rows deleted.
	 */
	public static function delete_all(): int {
		$deleted = 0;

		foreach ( self::get_encrypted_option_names() as $option_name ) {
			if ( delete_option( $option_name ) ) {
				++$deleted;
			}
		}

		return $deleted;
	}

	/**
	 * Migrate a plaintext option to encrypted storage.
	 *
	 * @param string $old_option_name Existing plaintext option name.
	 * @param string $new_secret_name New secret name (for encrypted storage).
	 * @return bool True if migrated successfully.
	 */
	public static function migrate_plaintext_option( string $old_option_name, string $new_secret_name ): bool {
		$plaintext = get_option( $old_option_name, '' );

		if ( empty( $plaintext ) ) {
			return false;
		}

		if ( self::set( $new_secret_name, $plaintext ) ) {
			delete_option( $old_option_name );
			return true;
		}

		return false;
	}


	/**
	 * Best-effort zero a key buffer.
	 *
	 * Only calls sodium_memzero() when the native libsodium extension is loaded.
	 * WordPress bundles a sodium_compat polyfill that registers sodium_memzero()
	 * but intentionally throws - so function_exists() alone is not a safe guard.
	 *
	 * @param string $key Buffer to zero (passed by reference).
	 * @return void
	 */
	private static function memzero( string &$key ): void {
		if ( extension_loaded( 'sodium' ) ) {
			sodium_memzero( $key );
		}
	}


	/**
	 * Derive the 32-byte encryption key from the shared master key via HKDF.
	 *
	 * @return string 32-byte derived key.
	 */
	private static function get_encryption_key(): string {
		$master = MasterKey::get();
		return hash_hkdf( 'sha256', $master, self::KEY_BYTES, self::HKDF_INFO );
	}

	/**
	 * Return all stored encrypted SiteFort option names.
	 *
	 * @return string[]
	 */
	private static function get_encrypted_option_names(): array {
		global $wpdb;

		if ( ! isset( $wpdb->options ) ) {
			return [];
		}

		$like = self::OPTION_PREFIX . '%';
		$table = $wpdb->options;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Enumerates encrypted option names before deleting through the Options API so object caches are invalidated.
		$rows = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT option_name FROM {$table} WHERE option_name LIKE %s",
				$like
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( ! is_array( $rows ) ) {
			return [];
		}

		return array_values(
			array_unique(
				array_filter(
					array_map(
						static function ( $option_name ): string {
							$normalized = is_string( $option_name ) ? $option_name : '';
							return str_starts_with( $normalized, self::OPTION_PREFIX ) ? $normalized : '';
						},
						$rows
					)
				)
			)
		);
	}

	/**
	 * Record a generic key mismatch when stored encrypted data cannot be decrypted
	 * even though the master key itself loaded successfully.
	 *
	 * @param string $secret_name Secret identifier without the option prefix.
	 * @return void
	 */
	private static function record_runtime_key_issue( string $secret_name ): void {
		if ( self::$runtime_key_issue || MasterKey::get_last_error() ) {
			return;
		}

		$secret_name = sanitize_key( $secret_name );
		self::$runtime_key_issue = [
			'code'           => 'key_changed',
			'message'        => $secret_name !== ''
				? sprintf(
					'The current encryption key cannot decrypt stored SiteFort secret data (%s).',
					$secret_name
				)
				: 'The current encryption key cannot decrypt stored SiteFort secret data.',
			'affected_secret' => $secret_name,
		];
	}


	/**
	 * Check whether the provider for an encrypted value is available.
	 */
	public static function is_envelope_provider_available( string $encrypted ): bool {
		if ( strncmp( $encrypted, self::ENVELOPE_SODIUM_AEAD, 3 ) === 0 ) {
			return self::is_sodium_aead_available();
		}

		if (
			strncmp( $encrypted, self::ENVELOPE_OPENSSL_AEAD, 3 ) === 0
			|| strncmp( $encrypted, self::ENVELOPE_OPENSSL, 3 ) === 0
		) {
			return self::is_openssl_available();
		}

		return self::is_sodium_available();
	}

	/**
	 * @return bool
	 */
	private static function is_sodium_aead_available(): bool {
		return extension_loaded( 'sodium' )
			&& function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' )
			&& function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt' )
			&& defined( 'SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES' );
	}

	/**
	 * @return bool
	 */
	private static function is_sodium_available(): bool {
		return extension_loaded( 'sodium' ) && function_exists( 'sodium_crypto_secretbox' );
	}

	/**
	 * @return bool
	 */
	private static function is_openssl_available(): bool {
		return extension_loaded( 'openssl' )
			&& function_exists( 'openssl_encrypt' )
			&& function_exists( 'openssl_decrypt' );
	}

}
