<?php
/**
 * Encryption utility.
 *
 * Provides authenticated encryption (AES-256-CBC + HMAC-SHA256) for
 * sensitive values stored at rest. Uses random IVs and domain-separated
 * key derivation from WordPress salt constants.
 *
 * @since 1.0.0
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP;

use RuntimeException;

defined( 'ABSPATH' ) || exit;

/**
 * Class Encryption
 *
 * Encrypts and decrypts sensitive values at rest using authenticated
 * encryption (AES-256-CBC with HMAC-SHA256, Encrypt-then-MAC).
 *
 * Output format: base64( iv[16] + ciphertext[N] + hmac[32] )
 *
 * @since 1.0.0
 */
class Encryption {

    /**
     * OpenSSL cipher algorithm.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const CIPHER = 'aes-256-cbc';

    /**
     * IV length in bytes for AES-256-CBC.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const IV_LENGTH = 16;

    /**
     * HMAC-SHA256 tag length in bytes.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const HMAC_LENGTH = 32;

    /**
     * WordPress default placeholder salt value.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const WP_DEFAULT_SALT = 'put your unique phrase here';

    /**
     * Minimum acceptable salt length in characters.
     *
     * Far below WordPress's own 64-char default, but still rules out trivial
     * placeholders like "abc" that would yield ~24 bits of practical entropy
     * after SHA-256 derivation.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const MIN_SALT_LENGTH = 32;

    /**
     * Minimum number of distinct characters required in a salt value.
     *
     * Catches degenerate values like 64 repetitions of the same character.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const MIN_SALT_UNIQUE_CHARS = 16;

    /**
     * Option key used to track decrypt failures so they surface as an admin notice.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const DECRYPT_FAILURE_OPTION = 'nimblymail_decrypt_failure';

    /**
     * Cached encryption key (derived once per request).
     *
     * @since 1.0.0
     *
     * @var string|null
     */
    private static ?string $encryption_key = null;

    /**
     * Cached HMAC key (derived once per request).
     *
     * @since 1.0.0
     *
     * @var string|null
     */
    private static ?string $hmac_key = null;

    /**
     * Encrypt a value using AES-256-CBC with a random IV and HMAC-SHA256 authentication.
     *
     * Generates a random IV, encrypts with AES-256-CBC, computes HMAC-SHA256 over
     * iv || ciphertext, and returns base64( iv + ciphertext + hmac ).
     *
     * @since 1.0.0
     *
     * @param string $value The plaintext value to encrypt.
     *
     * @return string The encrypted value as a base64-encoded string.
     *
     * @throws RuntimeException If OpenSSL is unavailable or encryption fails.
     */
    public static function encrypt( string $value ): string {
        if ( empty( $value ) ) {
            return '';
        }

        self::require_openssl();

        $key = self::get_encryption_key();
        $iv  = openssl_random_pseudo_bytes( self::IV_LENGTH );

        $ciphertext = openssl_encrypt( $value, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );

        if ( false === $ciphertext ) {
            throw new RuntimeException( esc_html__( 'NimblyMail: Encryption failed.', 'nimblymail-smtp' ) );
        }

        $hmac = self::compute_hmac( $iv . $ciphertext );

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

    /**
     * Decrypt an encrypted value.
     *
     * Extracts the IV (first 16 bytes), HMAC (last 32 bytes), and ciphertext (middle).
     * Verifies the HMAC before decrypting to prevent tampering.
     *
     * @since 1.0.0
     *
     * @param string $encrypted The encrypted value (base64-encoded).
     *
     * @return string The decrypted plaintext value.
     *
     * @throws RuntimeException If OpenSSL is unavailable, HMAC verification fails, or decryption fails.
     */
    public static function decrypt( string $encrypted ): string {
        if ( empty( $encrypted ) ) {
            return '';
        }

        self::require_openssl();

        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
        $raw = base64_decode( $encrypted, true );

        if ( false === $raw ) {
            throw new RuntimeException( esc_html__( 'NimblyMail: Invalid base64 data.', 'nimblymail-smtp' ) );
        }

        $min_length = self::IV_LENGTH + self::HMAC_LENGTH + 1;

        if ( strlen( $raw ) < $min_length ) {
            throw new RuntimeException( esc_html__( 'NimblyMail: Encrypted data is too short.', 'nimblymail-smtp' ) );
        }

        $iv         = substr( $raw, 0, self::IV_LENGTH );
        $hmac       = substr( $raw, -self::HMAC_LENGTH );
        $ciphertext = substr( $raw, self::IV_LENGTH, -self::HMAC_LENGTH );

        // Verify HMAC before decrypting (fail fast on tamper).
        $expected_hmac = self::compute_hmac( $iv . $ciphertext );

        if ( ! hash_equals( $expected_hmac, $hmac ) ) {
            throw new RuntimeException( esc_html__( 'NimblyMail: HMAC verification failed. Data may be tampered.', 'nimblymail-smtp' ) );
        }

        $key       = self::get_encryption_key();
        $decrypted = openssl_decrypt( $ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );

        if ( false === $decrypted ) {
            throw new RuntimeException( esc_html__( 'NimblyMail: Decryption failed.', 'nimblymail-smtp' ) );
        }

        return $decrypted;
    }

    /**
     * Derive the encryption key from AUTH_KEY with domain separation.
     *
     * @since 1.0.0
     *
     * @return string 32-byte binary encryption key.
     *
     * @throws RuntimeException If AUTH_KEY is not defined or is the default placeholder.
     */
    private static function get_encryption_key(): string {
        if ( null === self::$encryption_key ) {
            self::validate_salt( 'AUTH_KEY' );
            self::$encryption_key = hash( 'sha256', AUTH_KEY . ':nimblymail-smtp-encrypt', true );
        }

        return self::$encryption_key;
    }

    /**
     * Derive the HMAC key from NONCE_KEY with domain separation.
     *
     * @since 1.0.0
     *
     * @return string 32-byte binary HMAC key.
     *
     * @throws RuntimeException If NONCE_KEY is not defined or is the default placeholder.
     */
    private static function get_hmac_key(): string {
        if ( null === self::$hmac_key ) {
            self::validate_salt( 'NONCE_KEY' );
            self::$hmac_key = hash( 'sha256', NONCE_KEY . ':nimblymail-smtp-hmac', true );
        }

        return self::$hmac_key;
    }

    /**
     * Compute HMAC-SHA256 over the given data using the derived HMAC key.
     *
     * @since 1.0.0
     *
     * @param string $data The data to authenticate (iv || ciphertext).
     *
     * @return string 32-byte binary HMAC tag.
     *
     * @throws RuntimeException If HMAC key derivation fails.
     */
    private static function compute_hmac( string $data ): string {
        $hmac_key = self::get_hmac_key();

        return hash_hmac( 'sha256', $data, $hmac_key, true );
    }

    /**
     * Validate that a WordPress salt constant is defined and not the default placeholder.
     *
     * Also enforces a minimum length and a basic unique-character threshold so
     * trivial values (e.g. "abc" or 64 of the same character) are rejected
     * before they're collapsed through SHA-256.
     *
     * @since 1.0.0
     *
     * @param string $constant_name The constant name to validate (e.g. 'AUTH_KEY').
     *
     * @return void
     *
     * @throws RuntimeException If the constant is not defined, is the default placeholder, is too short, or has too little entropy.
     */
    private static function validate_salt( string $constant_name ): void {
        if ( ! defined( $constant_name ) ) {
            throw new RuntimeException(
                // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not HTML output.
                sprintf( 'NimblyMail: %s is not defined. Please configure WordPress salts in wp-config.php.', $constant_name )
            );
        }

        $value = (string) constant( $constant_name );

        if ( self::WP_DEFAULT_SALT === $value ) {
            throw new RuntimeException(
                // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not HTML output.
                sprintf( 'NimblyMail: %s is still the default placeholder. Please set unique salts in wp-config.php.', $constant_name )
            );
        }

        if ( strlen( $value ) < self::MIN_SALT_LENGTH ) {
            throw new RuntimeException(
                // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not HTML output.
                sprintf( 'NimblyMail: %s is too short. Use the WordPress salt generator to create a strong value.', $constant_name )
            );
        }

        if ( count( array_unique( str_split( $value ) ) ) < self::MIN_SALT_UNIQUE_CHARS ) {
            throw new RuntimeException(
                // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not HTML output.
                sprintf( 'NimblyMail: %s has too little entropy. Use the WordPress salt generator to create a strong value.', $constant_name )
            );
        }
    }

    /**
     * Compute a non-secret fingerprint of the current AUTH_KEY + NONCE_KEY pair.
     *
     * Used to detect salt rotation: stored alongside an encrypted value and
     * compared on decrypt failure so the plugin can distinguish "salts changed"
     * from "ciphertext was tampered with".
     *
     * @since 1.0.0
     *
     * @return string Hex digest, or empty string when salts are not yet configured.
     */
    public static function key_fingerprint(): string {
        if ( ! self::are_salts_configured() ) {
            return '';
        }

        return substr(
            hash( 'sha256', AUTH_KEY . '|' . NONCE_KEY . '|nimblymail-smtp-fp' ),
            0,
            16
        );
    }

    /**
     * Record a decrypt failure so the plugin can surface an admin notice.
     *
     * Stored against the current key fingerprint so a subsequent successful
     * encrypt under a different fingerprint can clear the warning.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public static function record_decrypt_failure(): void {
        if ( ! function_exists( 'update_option' ) ) {
            return;
        }

        $existing = get_option( self::DECRYPT_FAILURE_OPTION, array() );
        if ( ! is_array( $existing ) ) {
            $existing = array();
        }

        $fingerprint = self::key_fingerprint();
        $count       = isset( $existing['count'] ) ? (int) $existing['count'] : 0;

        update_option(
            self::DECRYPT_FAILURE_OPTION,
            array(
                'fingerprint' => $fingerprint,
                'first_seen'  => $existing['first_seen'] ?? time(),
                'last_seen'   => time(),
                'count'       => $count + 1,
            ),
            false
        );
    }

    /**
     * Whether decrypt failures have been recorded under the current key fingerprint.
     *
     * Returns false when the recorded fingerprint differs from the current one,
     * which indicates salts have been rotated since the failures were recorded
     * and the warning should not be shown for the new keys.
     *
     * @since 1.0.0
     *
     * @return bool
     */
    public static function has_recent_decrypt_failures(): bool {
        if ( ! function_exists( 'get_option' ) ) {
            return false;
        }

        $stored = get_option( self::DECRYPT_FAILURE_OPTION, array() );
        if ( ! is_array( $stored ) || empty( $stored['count'] ) ) {
            return false;
        }

        $stored_fp  = (string) ( $stored['fingerprint'] ?? '' );
        $current_fp = self::key_fingerprint();

        if ( '' === $stored_fp || '' === $current_fp ) {
            // count was already verified non-empty above; surface the warning
            // when we cannot fingerprint either side.
            return true;
        }

        return $stored_fp === $current_fp;
    }

    /**
     * Clear the recorded decrypt-failure state.
     *
     * Called from the admin notice "dismiss" handler and after a successful
     * re-save of the affected fields.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public static function clear_decrypt_failures(): void {
        if ( ! function_exists( 'delete_option' ) ) {
            return;
        }

        delete_option( self::DECRYPT_FAILURE_OPTION );
    }

    /**
     * Ensure OpenSSL extension is available.
     *
     * @since 1.0.0
     *
     * @return void
     *
     * @throws RuntimeException If the OpenSSL extension is not loaded.
     */
    private static function require_openssl(): void {
        if ( ! function_exists( 'openssl_encrypt' ) ) {
            throw new RuntimeException( esc_html__( 'NimblyMail: OpenSSL extension is required for encryption. Please enable ext-openssl in your PHP configuration.', 'nimblymail-smtp' ) );
        }
    }

    /**
     * Check whether WordPress salt constants are properly configured.
     *
     * Returns true if both AUTH_KEY and NONCE_KEY are defined, are not the
     * default WordPress placeholder, meet the minimum length, and have at
     * least the minimum number of distinct characters. Mirrors the checks
     * applied by {@see validate_salt()} so the admin notice fires before
     * the first encrypt throws.
     *
     * @since 1.0.0
     *
     * @return bool True if salts are properly configured.
     */
    public static function are_salts_configured(): bool {
        if ( ! defined( 'AUTH_KEY' ) || ! defined( 'NONCE_KEY' ) ) {
            return false;
        }

        return self::is_salt_strong( (string) AUTH_KEY )
            && self::is_salt_strong( (string) NONCE_KEY );
    }

    /**
     * Whether a salt value passes the strength checks applied at encrypt time.
     *
     * @since 1.0.0
     *
     * @param string $value Raw salt constant value.
     *
     * @return bool
     */
    private static function is_salt_strong( string $value ): bool {
        if ( self::WP_DEFAULT_SALT === $value ) {
            return false;
        }

        if ( strlen( $value ) < self::MIN_SALT_LENGTH ) {
            return false;
        }

        if ( count( array_unique( str_split( $value ) ) ) < self::MIN_SALT_UNIQUE_CHARS ) {
            return false;
        }

        return true;
    }

    /**
     * Check whether the OpenSSL extension is available.
     *
     * @since 1.0.0
     *
     * @return bool True if OpenSSL is available.
     */
    public static function is_openssl_available(): bool {
        return function_exists( 'openssl_encrypt' );
    }
}
