<?php

namespace Sibs\Stargate\Webhooks;

use Sibs\Stargate\Config\SibsConfig;
use Sibs\Stargate\Constants\Logs;
use Sibs\Stargate\Exceptions\SibsException;
use Sibs\Stargate\Loggers\SibsLogger;

defined('ABSPATH') || exit;

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

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

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

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

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

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

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

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

    /**
     * Constructor.
     *
     * @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.
     *
     * @return self
     */
    public function setSuppressLog(bool $suppressLog)
    {
        $this->suppressLog = $suppressLog;

        return $this;
    }

    /**
     * Execute Decryption.
     *
     * @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 ?? SibsConfig::getWebhookSecret()];
        $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');

        return $this->decrypt($body, $iv, $webhookSecretsInBase64, $authTag);
    }

    /**
     * Convert a string from one encode to another.
     *
     * @param mixed $string
     * @param mixed $to
     * @param mixed $from
     *
     * @return array|false|string
     */
    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.
     */
    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.
     */
    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');
    }

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

    /**
     * Verify if can Use LibSodium Extension.
     */
    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.
     */
    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');
    }

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

    /**
     * Verify if can use Sodium Compact.
     */
    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.
     */
    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');
    }

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

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

    /**
     * Verify if Sodium Compact can use crypto aead_aes256gcm.
     */
    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');
    }

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

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

    /**
     * Use Decrypt function with AES-256-GCM Algorithm.
     *
     * @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 (false !== $result && null !== json_decode($result)) {
                    return new SibsWebhookData($result);
                }

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

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

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

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

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

                if (false !== $result && null !== json_decode($result)) {
                    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.', Logs::WEBHOOK);
    }

    /**
     * Decrypt using sodium.
     *
     * @return false|string
     *
     * @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 (false === $result) {
                throw new SibsException(
                    $this->tryNum.') The encrypted text was bad formatted with sodium.',
                    Logs::WEBHOOK
                );
            }

            return $result;
        }

        return false;
    }

    /**
     * Decrypt using LibSodium.
     *
     * @return false|string
     *
     * @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 (false === $result) {
                throw new SibsException(
                    $this->tryNum.') The encrypted text was bad formatted with libsodium.',
                    Logs::WEBHOOK
                );
            }

            return $result;
        }

        return false;
    }

    /**
     * Decrypt using Sodium Compact.
     *
     * @return false|string
     *
     * @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 (false === $result) {
                throw new SibsException(
                    $this->tryNum.') The encrypted text was bad formatted with ParagonIE_Sodium_Compat.',
                    Logs::WEBHOOK
                );
            }

            return $result;
        }

        return false;
    }

    /**
     * Decrypt using Open SSL.
     *
     * @return false|string
     *
     * @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 (false === $result) {
                throw new SibsException(
                    $this->tryNum.') The encrypted text was bad formatted with Open SSL.',
                    Logs::WEBHOOK
                );
            }
        }

        return false;
    }

    /**
     * Validate and Log.
     */
    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.
     */
    private function selfLog(string $logType, string $logMessage = '')
    {
        if ($this->suppressLog) {
            return;
        }

        SibsLogger::$logType($logMessage, Logs::WEBHOOK);
    }
}
