<?php

namespace Twnt4All\Support;

use Twnt4All\Exception\OpenSslNotLoadedException;
use Twnt4All\Exception\PropertiesNotSetException;

/**
 * Authenticated symmetric encryption for merchant secrets stored in
 * wp_options (API token, certificate password).
 *
 * Preferred backend : `sodium_crypto_secretbox` (XChaCha20-Poly1305 —
 * built into PHP 7.2+ under the `sodium` extension). Falls back to
 * `openssl` AES-256-GCM when libsodium is missing. Legacy AES-256-CTR
 * ciphertexts written by the pre-2.4 versions are still decryptable :
 * the format tag at the start of the base64 payload steers dispatch,
 * so merchants who upgrade don't lose their stored token.
 *
 * The audit flagged the previous implementation for two reasons :
 *   1. AES-CTR is malleable (bit-flips propagate) without a MAC ;
 *   2. `LOGGED_IN_KEY` was used raw as the key. We now derive a
 *      cipher-specific key via HKDF-SHA256 from the concatenation of
 *      `LOGGED_IN_KEY` + `LOGGED_IN_SALT`.
 */
class Cipher
{
    private const TAG_SODIUM = 'S1:';   // sodium_crypto_secretbox
    private const TAG_GCM    = 'G1:';   // openssl aes-256-gcm

    /** @var string */
    private $secret_material;

    /** @var string */
    private $legacy_key;

    /** @var string */
    private $legacy_salt;

    public function __construct()
    {
        $this->legacy_key  = (string) $this->get_key();
        $this->legacy_salt = (string) $this->get_salt();
        // Concatenated input material for HKDF ; NOT a key on its own.
        $this->secret_material = $this->legacy_key . '|' . $this->legacy_salt;
    }

    /**
     * @throws OpenSslNotLoadedException
     * @throws PropertiesNotSetException
     * @throws \Exception
     */
    public function encrypt( $value )
    {
        $this->assert_env();
        $value = (string) $value;

        // Prefer libsodium (built-in on PHP 7.2+, mandatory since 7.2).
        if ( function_exists( 'sodium_crypto_secretbox' ) ) {
            $key       = $this->derive_key( 'p4all-twint-cipher-v1|sodium', SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
            $nonce     = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
            $cipher    = sodium_crypto_secretbox( $value, $nonce, $key );
            sodium_memzero( $key );
            return self::TAG_SODIUM . base64_encode( $nonce . $cipher );
        }

        // Fallback : AES-256-GCM. Authenticated, tag stored inline.
        if ( ! extension_loaded( 'openssl' ) ) {
            throw new OpenSslNotLoadedException( esc_html__( 'OpenSSL not activated.', 'pay4all-for-twint' ) );
        }
        $method = 'aes-256-gcm';
        $key    = $this->derive_key( 'p4all-twint-cipher-v1|gcm', 32 );
        $ivlen  = openssl_cipher_iv_length( $method );
        $iv     = random_bytes( $ivlen );
        $tag    = '';
        $cipher = openssl_encrypt( $value, $method, $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16 );
        if ( $cipher === false ) {
            throw new \Exception( 'Cipher failed.' );
        }
        return self::TAG_GCM . base64_encode( $iv . $tag . $cipher );
    }

    /**
     * @throws OpenSslNotLoadedException
     * @throws PropertiesNotSetException
     * @throws \Exception
     */
    public function decrypt( $raw_value )
    {
        $this->assert_env();
        $raw_value = (string) $raw_value;

        // Route by prefix tag ; treat everything without a known tag as
        // legacy AES-CTR ciphertext (pre-2.4 installs).
        if ( strncmp( $raw_value, self::TAG_SODIUM, 3 ) === 0 ) {
            return $this->decrypt_sodium( substr( $raw_value, 3 ) );
        }
        if ( strncmp( $raw_value, self::TAG_GCM, 3 ) === 0 ) {
            return $this->decrypt_gcm( substr( $raw_value, 3 ) );
        }
        return $this->decrypt_legacy_ctr( $raw_value );
    }

    // -----------------------------------------------------------------
    // Backends
    // -----------------------------------------------------------------

    private function decrypt_sodium( string $payload_b64 ) : string
    {
        $bin   = base64_decode( $payload_b64, true );
        if ( $bin === false || strlen( $bin ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
            throw new \Exception( 'Decryption failed.' );
        }
        $nonce  = substr( $bin, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
        $cipher = substr( $bin, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
        $key    = $this->derive_key( 'p4all-twint-cipher-v1|sodium', SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
        $out    = sodium_crypto_secretbox_open( $cipher, $nonce, $key );
        sodium_memzero( $key );
        if ( $out === false ) {
            throw new \Exception( 'Decryption failed.' );
        }
        return $out;
    }

    private function decrypt_gcm( string $payload_b64 ) : string
    {
        if ( ! extension_loaded( 'openssl' ) ) {
            throw new OpenSslNotLoadedException( esc_html__( 'OpenSSL not activated.', 'pay4all-for-twint' ) );
        }
        $bin    = base64_decode( $payload_b64, true );
        $method = 'aes-256-gcm';
        $ivlen  = openssl_cipher_iv_length( $method );
        if ( $bin === false || strlen( $bin ) <= $ivlen + 16 ) {
            throw new \Exception( 'Decryption failed.' );
        }
        $iv     = substr( $bin, 0, $ivlen );
        $tag    = substr( $bin, $ivlen, 16 );
        $cipher = substr( $bin, $ivlen + 16 );
        $key    = $this->derive_key( 'p4all-twint-cipher-v1|gcm', 32 );
        $out    = openssl_decrypt( $cipher, $method, $key, OPENSSL_RAW_DATA, $iv, $tag );
        if ( $out === false ) {
            throw new \Exception( 'Decryption failed.' );
        }
        return $out;
    }

    /**
     * Backward-compatible reader for the pre-2.4 unauthenticated
     * AES-CTR format. Kept so merchants upgrading from an older
     * version don't lose their stored API token / cert password ; new
     * writes always go through the authenticated backends above.
     */
    private function decrypt_legacy_ctr( string $b64 ) : string
    {
        if ( ! extension_loaded( 'openssl' ) ) {
            throw new OpenSslNotLoadedException( esc_html__( 'OpenSSL not activated.', 'pay4all-for-twint' ) );
        }
        // strict=true : refuse any non-base64 character. Pre-2.4 installs
        // stored the password in cleartext ; feeding « MyPassword123 »
        // through base64_decode() would happily succeed with a garbage
        // binary output. Rejecting here means the caller
        // (EnvironmentConfig::get_certificate_password) cleanly catches
        // and falls back to the legacy-cleartext branch instead of
        // triggering an « IV length » warning in openssl_decrypt.
        $raw_value = base64_decode( $b64, true );
        if ( $raw_value === false ) {
            throw new \Exception( 'Decryption failed.' );
        }
        $method = 'aes-256-ctr';
        $ivlen  = (int) openssl_cipher_iv_length( $method );
        // A legit legacy blob is base64( IV || ciphertext || salt ) —
        // at minimum ivlen bytes of IV + strlen(salt) bytes of trailer,
        // and any ciphertext body between them. Anything smaller can't
        // be a legacy value ; bail without calling openssl_decrypt so
        // we don't produce a PHP warning on cleartext data that
        // happened to base64-decode successfully.
        if ( strlen( $raw_value ) < $ivlen + strlen( $this->legacy_salt ) + 1 ) {
            throw new \Exception( 'Decryption failed.' );
        }
        $iv    = substr( $raw_value, 0, $ivlen );
        $body  = substr( $raw_value, $ivlen );
        // @openssl_decrypt : we've already guarded the IV length above,
        // but PHP still emits a warning if the ciphertext body happens
        // to be malformed for another reason. Silence it — the return
        // value drives the exception below.
        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see comment above.
        $value = @openssl_decrypt( $body, $method, $this->legacy_key, 0, $iv );
        if ( $value === false || substr( $value, -strlen( $this->legacy_salt ) ) !== $this->legacy_salt ) {
            throw new \Exception( 'Decryption failed.' );
        }
        return substr( $value, 0, -strlen( $this->legacy_salt ) );
    }

    // -----------------------------------------------------------------
    // Key derivation
    // -----------------------------------------------------------------

    /**
     * HKDF-SHA256 : derives a purpose-specific key from LOGGED_IN_KEY +
     * LOGGED_IN_SALT so we never use the WordPress cookie key raw.
     */
    private function derive_key( string $context, int $length ) : string
    {
        return hash_hkdf( 'sha256', $this->secret_material, $length, $context, (string) $this->legacy_salt );
    }

    private function assert_env() : void
    {
        if ( $this->legacy_key === '' ) {
            throw new PropertiesNotSetException( esc_html__( 'The constant LOGGED_IN_KEY is missing.', 'pay4all-for-twint' ) );
        }
        if ( $this->legacy_salt === '' ) {
            throw new PropertiesNotSetException( esc_html__( 'The constant LOGGED_IN_SALT is missing.', 'pay4all-for-twint' ) );
        }
    }

    /**
     * @return false|string
     */
    private function get_key()
    {
        if ( !defined( 'LOGGED_IN_KEY' ) || '' === LOGGED_IN_KEY ) {
            return false;
        }

        return LOGGED_IN_KEY;
    }

    /**
     * @return false|string
     */
    private function get_salt()
    {
        if ( !defined( 'LOGGED_IN_SALT' ) || '' === LOGGED_IN_SALT ) {
            return false;
        }

        return LOGGED_IN_SALT;
    }
}
