<?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 SecurWP\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 OPENSSL_IV_BYTES  = 12;
	private const OPENSSL_TAG_BYTES = 16;

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

	const OPTION_PREFIX = 'securwp_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.
	 * @return string Envelope-prefixed, base64-encoded ciphertext.
	 * @throws \RuntimeException If encryption fails.
	 */
	public static function encrypt( string $plaintext ): string {
		if ( self::is_sodium_available() ) {
			$key   = self::get_encryption_key();
			$nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );

			$ciphertext = sodium_crypto_secretbox( $plaintext, $nonce, $key );
			self::memzero( $key );

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

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

			$ciphertext = openssl_encrypt(
				$plaintext,
				'aes-256-gcm',
				$key,
				OPENSSL_RAW_DATA,
				$iv,
				$tag
			);

			self::memzero( $key );

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

			return self::ENVELOPE_OPENSSL . base64_encode( $iv . $tag . $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 ) {
		try {
			$version = self::ENVELOPE_SODIUM;
			$payload = $encrypted;

			if ( strncmp( $encrypted, self::ENVELOPE_SODIUM, strlen( self::ENVELOPE_SODIUM ) ) === 0 ) {
				$payload = substr( $encrypted, strlen( self::ENVELOPE_SODIUM ) );
				$version = self::ENVELOPE_SODIUM;
			} elseif ( strncmp( $encrypted, self::ENVELOPE_OPENSSL, strlen( self::ENVELOPE_OPENSSL ) ) === 0 ) {
				$payload = substr( $encrypted, strlen( self::ENVELOPE_OPENSSL ) );
				$version = self::ENVELOPE_OPENSSL;
			}

			$decoded = base64_decode( $payload, true );
			if ( false === $decoded ) {
				return false;
			}

			if ( $version === self::ENVELOPE_OPENSSL ) {
				if ( ! self::is_openssl_available() ) {
					return false;
				}

				$min_len = self::OPENSSL_IV_BYTES + self::OPENSSL_TAG_BYTES + 1;
				if ( 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 );

				$dec_key   = self::get_encryption_key();
				$plaintext = openssl_decrypt(
					$ciphertext,
					'aes-256-gcm',
					$dec_key,
					OPENSSL_RAW_DATA,
					$iv,
					$tag
				);

				self::memzero( $dec_key );

				return $plaintext;
			}

			if ( ! self::is_sodium_available() ) {
				return false;
			}

			$min_len = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES;
			if ( strlen( $decoded ) < $min_len ) {
				return false;
			}

			$nonce      = substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
			$ciphertext = substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );

			$dec_key   = self::get_encryption_key();
			$plaintext = sodium_crypto_secretbox_open( $ciphertext, $nonce, $dec_key );

			self::memzero( $dec_key );

			return $plaintext;
		} catch ( \SodiumException $e ) {
			// Do not log the raw SodiumException message; it may contain
			// implementation details about the key or ciphertext format.
			securwp_log( '[SecurWP SecretManager] Decryption failed (authentication/key error).' );
			return false;
		} catch ( \RuntimeException $e ) {
			securwp_log( '[SecurWP SecretManager] Decryption failed: ' . $e->getMessage() );
			return false;
		}
	}

	/**
	 * 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 );
			return update_option( self::OPTION_PREFIX . $name, $encrypted, false );
		} catch ( \SodiumException $e ) {
			securwp_log( '[SecurWP SecretManager] Failed to store secret (encryption error).' );
			return false;
		} catch ( \RuntimeException $e ) {
			securwp_log( '[SecurWP 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 );
		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 SecurWP 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 ) ) {
				$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 SecurWP 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 SecurWP 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 SecurWP secret data (%s).',
					$secret_name
				)
				: 'The current encryption key cannot decrypt stored SecurWP secret data.',
			'affected_secret' => $secret_name,
		];
	}


	/**
	 * @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' );
	}

}
