<?php

declare(strict_types=1);

namespace Jose\Component\Encryption;

use InvalidArgumentException;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\JWK;
use Jose\Component\Core\Util\Base64UrlSafe;
use Jose\Component\Core\Util\JsonConverter;
use Jose\Component\Core\Util\KeyChecker;
use Jose\Component\Encryption\Algorithm\ContentEncryptionAlgorithm;
use Jose\Component\Encryption\Algorithm\KeyEncryption\DirectEncryption;
use Jose\Component\Encryption\Algorithm\KeyEncryption\KeyAgreement;
use Jose\Component\Encryption\Algorithm\KeyEncryption\KeyAgreementWithKeyWrapping;
use Jose\Component\Encryption\Algorithm\KeyEncryption\KeyEncryption;
use Jose\Component\Encryption\Algorithm\KeyEncryption\KeyWrapping;
use Jose\Component\Encryption\Algorithm\KeyEncryptionAlgorithm;
use Jose\Component\Encryption\Compression\CompressionMethod;
use Jose\Component\Encryption\Compression\CompressionMethodManager;
use LogicException;
use RuntimeException;
use function array_key_exists;
use function count;
use function is_string;

class JWEBuilder
{
    protected ?JWK $senderKey = null;

    protected ?string $payload = null;

    protected ?string $aad = null;

    protected array $recipients = [];

    protected array $sharedProtectedHeader = [];

    protected array $sharedHeader = [];

    private ?CompressionMethod $compressionMethod = null;

    private ?string $keyManagementMode = null;

    private ?ContentEncryptionAlgorithm $contentEncryptionAlgorithm = null;

    private readonly AlgorithmManager $keyEncryptionAlgorithmManager;

    private readonly AlgorithmManager $contentEncryptionAlgorithmManager;

    public function __construct(
        AlgorithmManager $algorithmManager,
        null|AlgorithmManager $contentEncryptionAlgorithmManager = null,
        private readonly null|CompressionMethodManager $compressionManager = null
    ) {
        if ($compressionManager !== null) {
            trigger_deprecation(
                'web-token/jwt-library',
                '3.3.0',
                'The parameter "$compressionManager" is deprecated and will be removed in 4.0.0. Compression is not recommended for JWE. Please set "null" instead.'
            );
        }
        if ($contentEncryptionAlgorithmManager !== null) {
            trigger_deprecation(
                'web-token/jwt-library',
                '3.3.0',
                'The parameter "$contentEncryptionAlgorithmManager" is deprecated and will be removed in 4.0.0. Please set all algorithms in the first argument and set "null" instead.'
            );
            $this->keyEncryptionAlgorithmManager = $algorithmManager;
            $this->contentEncryptionAlgorithmManager = $contentEncryptionAlgorithmManager;
        } else {
            $keyEncryptionAlgorithms = [];
            $contentEncryptionAlgorithms = [];
            foreach ($algorithmManager->all() as $algorithm) {
                if ($algorithm instanceof KeyEncryptionAlgorithm) {
                    $keyEncryptionAlgorithms[] = $algorithm;
                }
                if ($algorithm instanceof ContentEncryptionAlgorithm) {
                    $contentEncryptionAlgorithms[] = $algorithm;
                }
            }
            $this->keyEncryptionAlgorithmManager = new AlgorithmManager($keyEncryptionAlgorithms);
            $this->contentEncryptionAlgorithmManager = new AlgorithmManager($contentEncryptionAlgorithms);
        }
    }

    /**
     * Reset the current data.
     */
    public function create(): self
    {
        $this->senderKey = null;
        $this->payload = null;
        $this->aad = null;
        $this->recipients = [];
        $this->sharedProtectedHeader = [];
        $this->sharedHeader = [];
        $this->compressionMethod = null;
        $this->keyManagementMode = null;

        return $this;
    }

    /**
     * Returns the key encryption algorithm manager.
     */
    public function getKeyEncryptionAlgorithmManager(): AlgorithmManager
    {
        return $this->keyEncryptionAlgorithmManager;
    }

    /**
     * Returns the content encryption algorithm manager.
     */
    public function getContentEncryptionAlgorithmManager(): AlgorithmManager
    {
        return $this->contentEncryptionAlgorithmManager;
    }

    /**
     * Returns the compression method manager.
     * @deprecated This method is deprecated and will be removed in v4.0. Compression is not recommended for JWE.
     */
    public function getCompressionMethodManager(): null|CompressionMethodManager
    {
        return $this->compressionManager;
    }

    /**
     * Set the payload of the JWE to build.
     */
    public function withPayload(string $payload): self
    {
        $clone = clone $this;
        $clone->payload = $payload;

        return $clone;
    }

    /**
     * Set the Additional Authenticated Data of the JWE to build.
     */
    public function withAAD(?string $aad): self
    {
        $clone = clone $this;
        $clone->aad = $aad;

        return $clone;
    }

    /**
     * Set the shared protected header of the JWE to build.
     */
    public function withSharedProtectedHeader(array $sharedProtectedHeader): self
    {
        $this->checkDuplicatedHeaderParameters($sharedProtectedHeader, $this->sharedHeader);
        foreach ($this->recipients as $recipient) {
            $this->checkDuplicatedHeaderParameters($sharedProtectedHeader, $recipient->getHeader());
        }
        $clone = clone $this;
        $clone->sharedProtectedHeader = $sharedProtectedHeader;

        return $clone;
    }

    /**
     * Set the shared header of the JWE to build.
     */
    public function withSharedHeader(array $sharedHeader): self
    {
        $this->checkDuplicatedHeaderParameters($this->sharedProtectedHeader, $sharedHeader);
        foreach ($this->recipients as $recipient) {
            $this->checkDuplicatedHeaderParameters($sharedHeader, $recipient->getHeader());
        }
        $clone = clone $this;
        $clone->sharedHeader = $sharedHeader;

        return $clone;
    }

    /**
     * Adds a recipient to the JWE to build.
     */
    public function addRecipient(JWK $recipientKey, array $recipientHeader = []): self
    {
        $this->checkDuplicatedHeaderParameters($this->sharedProtectedHeader, $recipientHeader);
        $this->checkDuplicatedHeaderParameters($this->sharedHeader, $recipientHeader);
        $clone = clone $this;
        $completeHeader = array_merge($clone->sharedHeader, $recipientHeader, $clone->sharedProtectedHeader);
        $clone->checkAndSetContentEncryptionAlgorithm($completeHeader);
        $keyEncryptionAlgorithm = $clone->getKeyEncryptionAlgorithm($completeHeader);
        if ($clone->keyManagementMode === null) {
            $clone->keyManagementMode = $keyEncryptionAlgorithm->getKeyManagementMode();
        } else {
            if (! $clone->areKeyManagementModesCompatible(
                $clone->keyManagementMode,
                $keyEncryptionAlgorithm->getKeyManagementMode()
            )) {
                throw new InvalidArgumentException('Foreign key management mode forbidden.');
            }
        }

        $compressionMethod = $clone->getCompressionMethod($completeHeader);
        if ($compressionMethod !== null) {
            if ($clone->compressionMethod === null) {
                $clone->compressionMethod = $compressionMethod;
            } elseif ($clone->compressionMethod->name() !== $compressionMethod->name()) {
                throw new InvalidArgumentException('Incompatible compression method.');
            }
        }
        if ($compressionMethod === null && $clone->compressionMethod !== null) {
            throw new InvalidArgumentException('Inconsistent compression method.');
        }
        $clone->checkKey($keyEncryptionAlgorithm, $recipientKey);
        $clone->recipients[] = [
            'key' => $recipientKey,
            'header' => $recipientHeader,
            'key_encryption_algorithm' => $keyEncryptionAlgorithm,
        ];

        return $clone;
    }

    //TODO: Verify if the key is compatible with the key encryption algorithm like is done to the ECDH-ES
    /**
     * Set the sender JWK to be used instead of the internal generated JWK
     */
    public function withSenderKey(JWK $senderKey): self
    {
        $clone = clone $this;
        $completeHeader = array_merge($clone->sharedHeader, $clone->sharedProtectedHeader);
        $keyEncryptionAlgorithm = $clone->getKeyEncryptionAlgorithm($completeHeader);
        if ($clone->keyManagementMode === null) {
            $clone->keyManagementMode = $keyEncryptionAlgorithm->getKeyManagementMode();
        } else {
            if (! $clone->areKeyManagementModesCompatible(
                $clone->keyManagementMode,
                $keyEncryptionAlgorithm->getKeyManagementMode()
            )) {
                throw new InvalidArgumentException('Foreign key management mode forbidden.');
            }
        }
        $clone->checkKey($keyEncryptionAlgorithm, $senderKey);
        $clone->senderKey = $senderKey;

        return $clone;
    }

    /**
     * Builds the JWE.
     */
    public function build(): JWE
    {
        if ($this->payload === null) {
            throw new LogicException('Payload not set.');
        }
        if (count($this->recipients) === 0) {
            throw new LogicException('No recipient.');
        }

        $additionalHeader = [];
        $cek = $this->determineCEK($additionalHeader);

        $recipients = [];
        foreach ($this->recipients as $recipient) {
            $recipient = $this->processRecipient($recipient, $cek, $additionalHeader);
            $recipients[] = $recipient;
        }

        if ((is_countable($additionalHeader) ? count($additionalHeader) : 0) !== 0 && count($this->recipients) === 1) {
            $sharedProtectedHeader = array_merge($additionalHeader, $this->sharedProtectedHeader);
        } else {
            $sharedProtectedHeader = $this->sharedProtectedHeader;
        }
        $encodedSharedProtectedHeader = count($sharedProtectedHeader) === 0 ? '' : Base64UrlSafe::encodeUnpadded(
            JsonConverter::encode($sharedProtectedHeader)
        );

        [$ciphertext, $iv, $tag] = $this->encryptJWE($cek, $encodedSharedProtectedHeader);

        return new JWE(
            $ciphertext,
            $iv,
            $tag,
            $this->aad,
            $this->sharedHeader,
            $sharedProtectedHeader,
            $encodedSharedProtectedHeader,
            $recipients
        );
    }

    private function checkAndSetContentEncryptionAlgorithm(array $completeHeader): void
    {
        $contentEncryptionAlgorithm = $this->getContentEncryptionAlgorithm($completeHeader);
        if ($this->contentEncryptionAlgorithm === null) {
            $this->contentEncryptionAlgorithm = $contentEncryptionAlgorithm;
        } elseif ($contentEncryptionAlgorithm->name() !== $this->contentEncryptionAlgorithm->name()) {
            throw new InvalidArgumentException('Inconsistent content encryption algorithm');
        }
    }

    private function processRecipient(array $recipient, string $cek, array &$additionalHeader): Recipient
    {
        $completeHeader = array_merge($this->sharedHeader, $recipient['header'], $this->sharedProtectedHeader);
        $keyEncryptionAlgorithm = $recipient['key_encryption_algorithm'];
        if (! $keyEncryptionAlgorithm instanceof KeyEncryptionAlgorithm) {
            throw new InvalidArgumentException('The key encryption algorithm is not valid');
        }
        $encryptedContentEncryptionKey = $this->getEncryptedKey(
            $completeHeader,
            $cek,
            $keyEncryptionAlgorithm,
            $additionalHeader,
            $recipient['key'],
            $recipient['sender_key'] ?? $this->senderKey ?? null
        );
        $recipientHeader = $recipient['header'];
        if ((is_countable($additionalHeader) ? count($additionalHeader) : 0) !== 0 && count($this->recipients) !== 1) {
            $recipientHeader = array_merge($recipientHeader, $additionalHeader);
            $additionalHeader = [];
        }

        return new Recipient($recipientHeader, $encryptedContentEncryptionKey);
    }

    private function encryptJWE(string $cek, string $encodedSharedProtectedHeader): array
    {
        if (! $this->contentEncryptionAlgorithm instanceof ContentEncryptionAlgorithm) {
            throw new InvalidArgumentException('The content encryption algorithm is not valid');
        }
        $iv_size = $this->contentEncryptionAlgorithm->getIVSize();
        $iv = $this->createIV($iv_size);
        $payload = $this->preparePayload();
        $tag = null;
        $ciphertext = $this->contentEncryptionAlgorithm->encryptContent(
            $payload ?? '',
            $cek,
            $iv,
            $this->aad,
            $encodedSharedProtectedHeader,
            $tag
        );

        return [$ciphertext, $iv, $tag];
    }

    private function preparePayload(): ?string
    {
        $prepared = $this->payload;
        if ($this->compressionMethod === null) {
            return $prepared;
        }

        return $this->compressionMethod->compress($prepared ?? '');
    }

    private function getEncryptedKey(
        array $completeHeader,
        string $cek,
        KeyEncryptionAlgorithm $keyEncryptionAlgorithm,
        array &$additionalHeader,
        JWK $recipientKey,
        ?JWK $senderKey
    ): ?string {
        if ($keyEncryptionAlgorithm instanceof KeyEncryption) {
            return $this->getEncryptedKeyFromKeyEncryptionAlgorithm(
                $completeHeader,
                $cek,
                $keyEncryptionAlgorithm,
                $recipientKey,
                $additionalHeader
            );
        }
        if ($keyEncryptionAlgorithm instanceof KeyWrapping) {
            return $this->getEncryptedKeyFromKeyWrappingAlgorithm(
                $completeHeader,
                $cek,
                $keyEncryptionAlgorithm,
                $recipientKey,
                $additionalHeader
            );
        }
        if ($keyEncryptionAlgorithm instanceof KeyAgreementWithKeyWrapping) {
            return $this->getEncryptedKeyFromKeyAgreementAndKeyWrappingAlgorithm(
                $completeHeader,
                $cek,
                $keyEncryptionAlgorithm,
                $additionalHeader,
                $recipientKey,
                $senderKey
            );
        }
        if ($keyEncryptionAlgorithm instanceof KeyAgreement) {
            return null;
        }
        if ($keyEncryptionAlgorithm instanceof DirectEncryption) {
            return null;
        }

        throw new InvalidArgumentException('Unsupported key encryption algorithm.');
    }

    private function getEncryptedKeyFromKeyAgreementAndKeyWrappingAlgorithm(
        array $completeHeader,
        string $cek,
        KeyAgreementWithKeyWrapping $keyEncryptionAlgorithm,
        array &$additionalHeader,
        JWK $recipientKey,
        ?JWK $senderKey
    ): string {
        if ($this->contentEncryptionAlgorithm === null) {
            throw new InvalidArgumentException('Invalid content encryption algorithm');
        }

        return $keyEncryptionAlgorithm->wrapAgreementKey(
            $recipientKey,
            $senderKey,
            $cek,
            $this->contentEncryptionAlgorithm->getCEKSize(),
            $completeHeader,
            $additionalHeader
        );
    }

    private function getEncryptedKeyFromKeyEncryptionAlgorithm(
        array $completeHeader,
        string $cek,
        KeyEncryption $keyEncryptionAlgorithm,
        JWK $recipientKey,
        array &$additionalHeader
    ): string {
        return $keyEncryptionAlgorithm->encryptKey($recipientKey, $cek, $completeHeader, $additionalHeader);
    }

    private function getEncryptedKeyFromKeyWrappingAlgorithm(
        array $completeHeader,
        string $cek,
        KeyWrapping $keyEncryptionAlgorithm,
        JWK $recipientKey,
        array &$additionalHeader
    ): string {
        return $keyEncryptionAlgorithm->wrapKey($recipientKey, $cek, $completeHeader, $additionalHeader);
    }

    private function checkKey(KeyEncryptionAlgorithm $keyEncryptionAlgorithm, JWK $recipientKey): void
    {
        if ($this->contentEncryptionAlgorithm === null) {
            throw new InvalidArgumentException('Invalid content encryption algorithm');
        }

        KeyChecker::checkKeyUsage($recipientKey, 'encryption');
        if ($keyEncryptionAlgorithm->name() !== 'dir') {
            KeyChecker::checkKeyAlgorithm($recipientKey, $keyEncryptionAlgorithm->name());
        } else {
            KeyChecker::checkKeyAlgorithm($recipientKey, $this->contentEncryptionAlgorithm->name());
        }
    }

    private function determineCEK(array &$additionalHeader): string
    {
        if ($this->contentEncryptionAlgorithm === null) {
            throw new InvalidArgumentException('Invalid content encryption algorithm');
        }

        switch ($this->keyManagementMode) {
            case KeyEncryption::MODE_ENCRYPT:
            case KeyEncryption::MODE_WRAP:
                return $this->createCEK($this->contentEncryptionAlgorithm->getCEKSize());

            case KeyEncryption::MODE_AGREEMENT:
                if (count($this->recipients) !== 1) {
                    throw new LogicException(
                        'Unable to encrypt for multiple recipients using key agreement algorithms.'
                    );
                }
                $recipientKey = $this->recipients[0]['key'];
                $senderKey = $this->recipients[0]['sender_key'] ?? null;
                $algorithm = $this->recipients[0]['key_encryption_algorithm'];
                if (! $algorithm instanceof KeyAgreement) {
                    throw new InvalidArgumentException('Invalid content encryption algorithm');
                }
                $completeHeader = array_merge(
                    $this->sharedHeader,
                    $this->recipients[0]['header'],
                    $this->sharedProtectedHeader
                );

                return $algorithm->getAgreementKey(
                    $this->contentEncryptionAlgorithm->getCEKSize(),
                    $this->contentEncryptionAlgorithm->name(),
                    $recipientKey,
                    $senderKey,
                    $completeHeader,
                    $additionalHeader
                );

            case KeyEncryption::MODE_DIRECT:
                if (count($this->recipients) !== 1) {
                    throw new LogicException(
                        'Unable to encrypt for multiple recipients using key agreement algorithms.'
                    );
                }
                /** @var JWK $key */
                $key = $this->recipients[0]['key'];
                if ($key->get('kty') !== 'oct') {
                    throw new RuntimeException('Wrong key type.');
                }
                $k = $key->get('k');
                if (! is_string($k)) {
                    throw new RuntimeException('Invalid key.');
                }

                return Base64UrlSafe::decodeNoPadding($k);

            default:
                throw new InvalidArgumentException(sprintf(
                    'Unsupported key management mode "%s".',
                    $this->keyManagementMode
                ));
        }
    }

    private function getCompressionMethod(array $completeHeader): ?CompressionMethod
    {
        if ($this->compressionManager === null || ! array_key_exists('zip', $completeHeader)) {
            return null;
        }

        return $this->compressionManager->get($completeHeader['zip']);
    }

    private function areKeyManagementModesCompatible(string $current, string $new): bool
    {
        $agree = KeyEncryptionAlgorithm::MODE_AGREEMENT;
        $dir = KeyEncryptionAlgorithm::MODE_DIRECT;
        $enc = KeyEncryptionAlgorithm::MODE_ENCRYPT;
        $wrap = KeyEncryptionAlgorithm::MODE_WRAP;
        $supportedKeyManagementModeCombinations = [
            $enc . $enc => true,
            $enc . $wrap => true,
            $wrap . $enc => true,
            $wrap . $wrap => true,
            $agree . $agree => false,
            $agree . $dir => false,
            $agree . $enc => false,
            $agree . $wrap => false,
            $dir . $agree => false,
            $dir . $dir => false,
            $dir . $enc => false,
            $dir . $wrap => false,
            $enc . $agree => false,
            $enc . $dir => false,
            $wrap . $agree => false,
            $wrap . $dir => false,
        ];

        if (array_key_exists($current . $new, $supportedKeyManagementModeCombinations)) {
            return $supportedKeyManagementModeCombinations[$current . $new];
        }

        return false;
    }

    private function createCEK(int $size): string
    {
        return random_bytes($size / 8);
    }

    private function createIV(int $size): string
    {
        return random_bytes($size / 8);
    }

    private function getKeyEncryptionAlgorithm(array $completeHeader): KeyEncryptionAlgorithm
    {
        if (! isset($completeHeader['alg'])) {
            throw new InvalidArgumentException('Parameter "alg" is missing.');
        }
        $keyEncryptionAlgorithm = $this->keyEncryptionAlgorithmManager->get($completeHeader['alg']);
        if (! $keyEncryptionAlgorithm instanceof KeyEncryptionAlgorithm) {
            throw new InvalidArgumentException(sprintf(
                'The key encryption algorithm "%s" is not supported or not a key encryption algorithm instance.',
                $completeHeader['alg']
            ));
        }

        return $keyEncryptionAlgorithm;
    }

    private function getContentEncryptionAlgorithm(array $completeHeader): ContentEncryptionAlgorithm
    {
        if (! isset($completeHeader['enc'])) {
            throw new InvalidArgumentException('Parameter "enc" is missing.');
        }
        $contentEncryptionAlgorithm = $this->contentEncryptionAlgorithmManager->get($completeHeader['enc']);
        if (! $contentEncryptionAlgorithm instanceof ContentEncryptionAlgorithm) {
            throw new InvalidArgumentException(sprintf(
                'The content encryption algorithm "%s" is not supported or not a content encryption algorithm instance.',
                $completeHeader['enc']
            ));
        }

        return $contentEncryptionAlgorithm;
    }

    private function checkDuplicatedHeaderParameters(array $header1, array $header2): void
    {
        $inter = array_intersect_key($header1, $header2);
        if (count($inter) !== 0) {
            throw new InvalidArgumentException(sprintf(
                'The header contains duplicated entries: %s.',
                implode(', ', array_keys($inter))
            ));
        }
    }
}
