<?php

declare(strict_types=1);

/*
 * This file is part of the VITICORE2 package.
 *
 * (c) CP Creation <web@cpcreation.fr>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CpCreation\VitiCore\Commerce\Catalog\Configuration;

/**
 * Chiffre les secrets Commerce au repos avec une enveloppe AEAD versionnée.
 *
 * La finalité fait partie des données authentifiées afin qu'un chiffré ne
 * puisse pas être déplacé d'un champ sensible à un autre.
 */
final readonly class CatalogSecretCipher
{
    public const string PURPOSE_ENROLLMENT_TOKEN = 'enrollment-token';
    public const string PURPOSE_WEBHOOK_SECRET = 'webhook-secret';
    public const string PURPOSE_AUTHORIZATION_PKCE_VERIFIER = 'authorization-pkce-verifier';

    private const string CIPHER = 'aes-256-gcm';
    private const string VERSION = 'v1';
    private const string KEY_CONTEXT = 'viticore-commerce-catalog-secrets-v1';
    private const string AAD_PREFIX = 'viticore-commerce-catalog:';
    private const int NONCE_LENGTH = 12;
    private const int TAG_LENGTH = 16;

    public function __construct(
        #[\SensitiveParameter]
        private string $appSecret,
    ) {}

    public function encrypt(
        #[\SensitiveParameter]
        string $plaintext,
        string $purpose,
    ): string {
        if ('' === $plaintext) {
            throw new \InvalidArgumentException('Un secret Commerce vide ne peut pas être chiffré.');
        }

        $nonce = random_bytes(self::NONCE_LENGTH);
        $tag = '';
        $ciphertext = openssl_encrypt(
            $plaintext,
            self::CIPHER,
            $this->key(),
            \OPENSSL_RAW_DATA,
            $nonce,
            $tag,
            $this->associatedData($purpose),
            self::TAG_LENGTH,
        );

        if (false === $ciphertext || self::TAG_LENGTH !== mb_strlen($tag, '8bit')) {
            throw new \RuntimeException('Le secret Commerce n’a pas pu être chiffré.');
        }

        return implode('.', [
            self::VERSION,
            self::base64UrlEncode($nonce),
            self::base64UrlEncode($ciphertext),
            self::base64UrlEncode($tag),
        ]);
    }

    public function decrypt(
        #[\SensitiveParameter]
        string $envelope,
        string $purpose,
    ): string {
        $parts = explode('.', $envelope);
        if (4 !== \count($parts) || self::VERSION !== $parts[0]) {
            throw new \RuntimeException('L’enveloppe du secret Commerce est invalide.');
        }

        $nonce = self::base64UrlDecode($parts[1]);
        $ciphertext = self::base64UrlDecode($parts[2], allowEmpty: true);
        $tag = self::base64UrlDecode($parts[3]);
        if (self::NONCE_LENGTH !== mb_strlen($nonce, '8bit') || self::TAG_LENGTH !== mb_strlen($tag, '8bit')) {
            throw new \RuntimeException('L’enveloppe du secret Commerce est invalide.');
        }

        $plaintext = openssl_decrypt(
            $ciphertext,
            self::CIPHER,
            $this->key(),
            \OPENSSL_RAW_DATA,
            $nonce,
            $tag,
            $this->associatedData($purpose),
        );

        if (false === $plaintext || '' === $plaintext) {
            throw new \RuntimeException('Le secret Commerce ne peut pas être déchiffré.');
        }

        return $plaintext;
    }

    private function key(): string
    {
        if ('' === mb_trim($this->appSecret)) {
            throw new \LogicException('APP_SECRET doit être configuré pour protéger les secrets Commerce.');
        }

        return hash_hkdf('sha256', $this->appSecret, 32, self::KEY_CONTEXT);
    }

    private function associatedData(string $purpose): string
    {
        $purpose = mb_trim($purpose);
        if ('' === $purpose || mb_strlen($purpose) > 128) {
            throw new \InvalidArgumentException('La finalité du secret Commerce est invalide.');
        }

        return self::AAD_PREFIX . $purpose;
    }

    private static function base64UrlEncode(string $value): string
    {
        return mb_rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
    }

    private static function base64UrlDecode(string $value, bool $allowEmpty = false): string
    {
        if ((!$allowEmpty && '' === $value) || ('' !== $value && 1 !== preg_match('/^[A-Za-z0-9_-]+$/D', $value))) {
            throw new \RuntimeException('L’enveloppe du secret Commerce est invalide.');
        }

        $padding = (4 - mb_strlen($value, '8bit') % 4) % 4;
        $decoded = base64_decode(strtr($value, '-_', '+/') . str_repeat('=', $padding), true);
        if (false === $decoded || self::base64UrlEncode($decoded) !== $value) {
            throw new \RuntimeException('L’enveloppe du secret Commerce est invalide.');
        }

        return $decoded;
    }
}
