<?php

namespace Sibs;

use Configuration;
use SodiumException;

class SibsWebhookDecryptor
{
    const AES_256_GCM = 'AES-256-GCM';

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

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

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

    /**
     * @var ?string
     */
    private $secretBase64;

    /**
     * @var int
     */
    private $tryNum;

    /**
     * @var bool
     */
    private $suppressLog;

    const DEBUG   = 'debug';
    const INFO    = 'info';
    const WARNING = 'warning';
    const ERROR   = 'error';

    /**
     * Constructor.
     *
     * @param string $body
     * @param string $iv
     * @param string $authTag
     * @param ?string $secret
     */
    public function __construct(string $body, string $iv, string $authTag, $secret = null)
    {
        $this->bodyBase64    = $body;
        $this->ivBase64      = $iv;
        $this->authTagBase64 = $authTag;
        $this->secretBase64  = $secret;

        $this->tryNum      = 1;
        $this->suppressLog = false;
    }

    /**
     * Set the value of suppressLog.
     *
     * @param bool $suppressLog
     * @return self
     */
    public function setSuppressLog(bool $suppressLog)
    {
        $this->suppressLog = $suppressLog;

        return $this;
    }

    /**
     * Execute Decryption.
     *
     * @return SibsWebhookData
     * @throws SibsException
     */
    public function execute(): SibsWebhookData
    {
        $this->selfLog(self::INFO, 'Begin to try to decrypt webhook');
        $this->selfLog(self::INFO, ('PHP_VERSION_ID: ' . PHP_VERSION_ID));

        $webhookSecretsInBase64 = [$this->secretBase64 ?? Configuration::get('SIBS_WEBHOOK_SECRET')];
        $this->selfLog(self::INFO, ('Webhook secrets in base 64: ' . SibsLogger::prettify($webhookSecretsInBase64)));

        $this->selfLog(self::INFO, ('IV (base 64): ' . $this->ivBase64));
        $iv = $this->convert_encode($this->ivBase64);
        $this->selfLog(self::INFO, 'IV converted to utf-8');

        $this->selfLog(self::INFO, ('AUTH (base 64): ' . $this->authTagBase64));
        $authTag = $this->convert_encode($this->authTagBase64);
        $this->selfLog(self::INFO, 'AUTH converted to utf-8');

        $this->selfLog(self::INFO, ('BODY (base 64): ' . $this->bodyBase64));
        $body = $this->convert_encode($this->bodyBase64);
        $this->selfLog(self::INFO, 'BODY converted to utf-8');

        $bodyDecrypted = $this->decrypt($body, $iv, $webhookSecretsInBase64, $authTag);

        return $bodyDecrypted;
    }

    /**
     * Use Decrypt function with AES-256-GCM Algorithm.
     * @param string $body
     * @param string $iv
     * @param array $webhookSecretsInBase64
     * @param string $authTag
     * @return SibsWebhookData
     * @throws SibsException
     */
    private function decrypt(string $body, string $iv, array $webhookSecretsInBase64, string $authTag): SibsWebhookData
    {
        $encryptedBody = $body . $authTag;

        foreach ($webhookSecretsInBase64 as $key => $webhookSecretInBase64) {
            $this->tryNum = ($key + 1);
            $this->selfLog(self::INFO, ($this->tryNum . ') Webhook Secret (base 64): ' . $webhookSecretInBase64));

            if (empty($webhookSecretInBase64)) {
                continue;
            }

            $webhookSecret = $this->convert_encode($webhookSecretInBase64);
            $this->selfLog(self::INFO, ($this->tryNum . ') Converted Webhook Secret to utf-8'));

            try {
                /*
                    To verify if it decoded correctly, we first verify:
                    - if the $result is false, since sodium returns false when cannot decrypt
                    - and if json_decode($result) === null, because if, for some reason,
                    sodium thinks that decrypted correctly, $result must be a valid json

                    !($result === false && json_decode($result) === null)
                                    the same as
                    $result !== false || json_decode($result) !== null
                */

                // Try to decrypt with sodium
                $result = $this->sodiumDecrypt($encryptedBody, $iv, $webhookSecret);

                if ($result !== false && json_decode($result) !== null) {
                    return new SibsWebhookData($result);
                }

                // Try to decrypt with libsodium
                $result = $this->libSodiumDecrypt($encryptedBody, $iv, $webhookSecret);

                if ($result !== false && json_decode($result) !== null) {
                    return new SibsWebhookData($result);
                }

                // Try to decrypt with sodium compact
                $result = $this->sodiumCompactDecrypt($encryptedBody, $iv, $webhookSecret);

                if ($result !== false && json_decode($result) !== null) {
                    return new SibsWebhookData($result);
                }

                // Try to decrypt with open ssl
                $result = $this->openSSLDecrypt($body, $iv, $authTag, $webhookSecret);

                if ($result !== false && json_decode($result) !== null) {
                    return new SibsWebhookData($result);
                }
            } catch (SodiumException $e) {
                $this->selfLog(self::ERROR, ($this->tryNum . ') SodiumException: ' . $e->getMessage()));
            } catch (SibsException $e) {
                $this->selfLog(self::ERROR, ($this->tryNum . ') sibsException: ' . $e->getMessage()));
            }
        }

        throw new SibsException('There was some problem while decrypting the webhook message.');
    }

    /**
     * Convert a string from one encode to another.
     *
     * @param mixed $string
     * @param mixed $to
     * @param mixed $from
     * @return array|string|false
     */
    public function convert_encode($string, $to = 'UTF-8', $from = 'BASE64')
    {
        if (ctype_xdigit($string)) {
            return hex2bin($string);
        }

        return mb_convert_encoding($string, $to, $from);
    }

    /**
     * Verify if has Sodium Extension.
     *
     * @return bool
     */
    public function hasSodiumExtension(): bool
    {
        return extension_loaded('sodium');
    }

    /**
     * Verify if can use Sodium Extension.
     *
     * @return bool
     */
    public function canUseSodiumExtension()
    {
        return is_callable('sodium_crypto_aead_aes256gcm_is_available') &&
            sodium_crypto_aead_aes256gcm_is_available();
    }

    /**
     * Verify if Sodium can use crypto aead_aes256gcm.
     *
     * @return bool
     */
    public function canSodiumUseAeadAes256Gcm(): bool
    {
        $this->selfLog(self::DEBUG, 'Sodium Can Use AES-256-GCM Decrypt');

        return $this->validateAndLog((PHP_VERSION_ID >= 70000), '(PHP VERSION >= 7.0)') &&
            $this->validateAndLog($this->hasSodiumExtension(), 'Has Sodium Extension') &&
            $this->validateAndLog($this->canUseSodiumExtension(), 'Can Use Sodium Extension');
    }

    /**
     * Decrypt using sodium.
     *
     * @param string $encryptedBody
     * @param string $iv
     * @param string $webhookSecret
     * @return string|false
     * @throws SibsException
     * @throws SodiumException
     */
    private function sodiumDecrypt(string $encryptedBody, string $iv, string $webhookSecret)
    {
        if ($this->canSodiumUseAeadAes256Gcm()) {
            $result = sodium_crypto_aead_aes256gcm_decrypt($encryptedBody, '', $iv, $webhookSecret);
            $this->selfLog(self::INFO, ($this->tryNum . ') Decrypt sodium method. Result: ' . $result));

            if ($result === false) {
                throw new SibsException(
                    $this->tryNum . ') The encrypted text was bad formatted with sodium.'
                );
            }

            return $result;
        }

        return false;
    }

    /**
     * Verify if has LibSodium Extension.
     *
     * @return bool
     */
    public function hasLibSodiumExtension(): bool
    {
        return extension_loaded('libsodium');
    }

    /**
     * Verify if can Use LibSodium Extension.
     *
     * @return bool
     */
    public function canUseLibSodiumExtension(): bool
    {
        return is_callable('\\Sodium\\crypto_aead_aes256gcm_is_available') &&
            \Sodium\crypto_aead_aes256gcm_is_available();
    }

    /**
     * Verify if Sodium can use crypto aead_aes256gcm.
     *
     * @return bool
     */
    public function canLibSodiumUseAeadAes256Gcm(): bool
    {
        $this->selfLog(self::DEBUG, 'LibSodium Can Use AES-256-GCM Decrypt');

        return $this->validateAndLog((PHP_VERSION_ID >= 50300), '(PHP VERSION >= 5.3)') &&
            $this->validateAndLog($this->hasLibSodiumExtension(), 'Has LibSodium Extension') &&
            $this->validateAndLog($this->canUseLibSodiumExtension(), 'Can Use LibSodium Extension');
    }

    /**
     * Decrypt using LibSodium.
     *
     * @param string $encryptedBody
     * @param string $iv
     * @param string $webhookSecret
     * @return string|false
     * @throws SibsException
     * @throws SodiumException
     */
    private function libSodiumDecrypt(string $encryptedBody, string $iv, string $webhookSecret)
    {
        if ($this->canLibSodiumUseAeadAes256Gcm()) {
            $result = \Sodium\crypto_aead_aes256gcm_decrypt($encryptedBody, '', $iv, $webhookSecret);
            $this->selfLog(self::INFO, ($this->tryNum . ') Decrypt libsodium method. Result: ' . $result));

            if ($result === false) {
                throw new SibsException(
                    $this->tryNum . ') The encrypted text was bad formatted with libsodium.'
                );
            }

            return $result;
        }

        return false;
    }

    /**
     * Verify if has Sodium Compact.
     *
     * @return bool
     */
    public function hasSodiumCompact(): bool
    {
        return class_exists('\\ParagonIE_Sodium_Compat');
    }

    /**
     * Verify if can use Sodium Compact.
     *
     * @return bool
     */
    public function canUseSodiumCompact(): bool
    {
        return is_callable('\\ParagonIE_Sodium_Compat::crypto_aead_aes256gcm_is_available') &&
            \ParagonIE_Sodium_Compat::crypto_aead_aes256gcm_is_available();
    }

    /**
     * Verify if Sodium Compact can use crypto aead_aes256gcm.
     *
     * @return bool
     */
    public function canSodiumCompactUseAeadAes256Gcm(): bool
    {
        $this->selfLog(self::DEBUG, 'Sodium Compact Can Use AES-256-GCM Decrypt');

        return $this->validateAndLog((PHP_VERSION_ID >= 50300), '(PHP VERSION >= 5.3)') &&
            $this->validateAndLog($this->hasSodiumCompact(), 'Has Sodium Compact') &&
            $this->validateAndLog($this->canUseSodiumCompact(), 'Can Use Sodium Compact');
    }

    /**
     * Decrypt using Sodium Compact.
     *
     * @param string $encryptedBody
     * @param string $iv
     * @param string $webhookSecret
     * @return string|false
     * @throws SibsException
     * @throws SodiumException
     */
    private function sodiumCompactDecrypt(string $encryptedBody, string $iv, string $webhookSecret)
    {
        if ($this->canSodiumCompactUseAeadAes256Gcm()) {
            $result = \ParagonIE_Sodium_Compat::crypto_aead_aes256gcm_decrypt($encryptedBody, '', $iv, $webhookSecret);
            $this->selfLog(self::INFO, ($this->tryNum . ') Decrypt ParagonIE_Sodium_Compat method. Result: ' . $result));

            if ($result === false) {
                throw new SibsException(
                    $this->tryNum . ') The encrypted text was bad formatted with ParagonIE_Sodium_Compat.'
                );
            }

            return $result;
        }

        return false;
    }

    /**
     * Verify if has Open SSL Extension.
     *
     * @return bool
     */
    public function hasOpenSSLExtension(): bool
    {
        return extension_loaded('openssl');
    }

    /**
     * Verify if can use Open SSL Extension.
     *
     * @return bool
     */
    public function canUseOpenSSLExtension(): bool
    {
        return is_callable('openssl_encrypt') &&
            is_callable('openssl_decrypt');
    }

    /**
     * Verify if Sodium Compact can use crypto aead_aes256gcm.
     *
     * @return bool
     */
    public function canOpenSSLUseAeadAes256Gcm(): bool
    {
        $this->selfLog(self::DEBUG, 'OpenSSL Can Use AES-256-GCM Decrypt');

        return $this->validateAndLog((PHP_VERSION_ID >= 50300), '(PHP VERSION >= 5.3)') &&
            $this->validateAndLog($this->hasOpenSSLExtension(), 'Has OpenSSL Extension') &&
            $this->validateAndLog($this->canUseOpenSSLExtension(), 'Can Use OpenSSL Extension');
    }

    /**
     * Decrypt using Open SSL.
     *
     * @param string $body
     * @param string $iv
     * @param string $authTag
     * @param string $webhookSecret
     * @return string|false
     * @throws SibsException
     */
    private function openSSLDecrypt(string $body, string $iv, string $authTag, string $webhookSecret)
    {
        if ($this->canOpenSSLUseAeadAes256Gcm()) {
            $result = openssl_decrypt($body, self::AES_256_GCM, $webhookSecret, OPENSSL_RAW_DATA, $iv, $authTag);
            $this->selfLog(self::INFO, ($this->tryNum . ') Decrypt Open SSL method. Result: ' . $result));

            if ($result === false) {
                throw new SibsException(
                    $this->tryNum . ') The encrypted text was bad formatted with Open SSL.'
                );
            }
        }

        return false;
    }

    /**
     * Verify if has Sodium or Open SSL.
     *
     * @return bool
     */
    public function hasSodiumOrOpenSSL(): bool
    {
        return
            $this->hasSodiumExtension() ||
            $this->hasLibSodiumExtension() ||
            $this->hasSodiumCompact() ||
            $this->hasOpenSSLExtension();
    }

    /**
     * Verify if can Use AES-256-GCM.
     *
     * @return bool
     */
    public function canUseAes256Gcm(): bool
    {
        return
            $this->canSodiumUseAeadAes256Gcm() ||
            $this->canLibSodiumUseAeadAes256Gcm() ||
            $this->canSodiumCompactUseAeadAes256Gcm() ||
            $this->canOpenSSLUseAeadAes256Gcm();
    }

    /**
     * Validate and Log.
     *
     * @param bool $validation
     * @param string $logMessage
     * @return bool
     */
    private function validateAndLog(bool $validate, string $logMessage = ''): bool
    {
        $logType = (($validate) ? self::DEBUG : self::WARNING);
        $this->selfLog($logType, ($logMessage . ' = ' . (($validate) ? 'true' : 'false')));

        return $validate;
    }

    /**
     * Self Log Log.
     *
     * @param string $logType
     * @param string $logMessage
     */
    private function selfLog(string $logType, string $logMessage = '')
    {
        if ($this->suppressLog) {
            return;
        }

        call_user_func_array([SibsLogger::class, $logType], [$logMessage]);
    }
}
