<?php

declare(strict_types=1);

namespace PhpWA\Storage;

use InvalidArgumentException;
use PhpWA\Crypto\CryptoPrimitives;
use PhpWA\Crypto\RandomSourceInterface;
use PhpWA\Crypto\SystemRandomSource;
use PhpWA\Exception\StorageException;

/**
 * Versioned AEAD envelope with key rotation.
 *
 * The context is authenticated and also used for key separation. A ciphertext
 * copied to another account or record type therefore cannot be decrypted.
 */
final class AesGcmKeyringCipher implements SecretCipherInterface
{
    private const VERSION = 1;
    private const MAX_PLAINTEXT_BYTES = 67108864;
    private const MAX_ENVELOPE_BYTES = 94371840;

    /** @var string */ private $activeKeyId;
    /** @var array<string,string> */ private $keys;
    /** @var RandomSourceInterface */ private $random;

    /**
     * @param array<string,string> $keys 32-byte master keys indexed by key ID
     */
    public function __construct(string $activeKeyId, array $keys, ?RandomSourceInterface $random = null)
    {
        self::assertKeyId($activeKeyId);
        if (!isset($keys[$activeKeyId])) {
            throw new InvalidArgumentException('The active storage key is missing from the keyring.');
        }
        if (count($keys) < 1 || count($keys) > 32) {
            throw new InvalidArgumentException('The storage keyring must contain between 1 and 32 keys.');
        }
        foreach ($keys as $keyId => $key) {
            if (!is_string($keyId) || !is_string($key)) {
                throw new InvalidArgumentException('Storage key IDs and keys must be strings.');
            }
            self::assertKeyId($keyId);
            if (strlen($key) !== 32) {
                throw new InvalidArgumentException('Each storage master key must contain exactly 32 bytes.');
            }
        }
        $this->activeKeyId = $activeKeyId;
        $this->keys = $keys;
        $this->random = $random ?? new SystemRandomSource();
    }

    public function encrypt(string $context, string $plaintext): string
    {
        self::assertContext($context);
        if (strlen($plaintext) > self::MAX_PLAINTEXT_BYTES) {
            throw new StorageException('Secret payload exceeds the storage limit.');
        }

        $nonce = $this->random->bytes(12);
        if (strlen($nonce) !== 12) {
            throw new StorageException('Unable to generate a storage nonce.');
        }
        $key = $this->deriveKey($this->keys[$this->activeKeyId], $context);
        $aad = $this->aad($this->activeKeyId, $context);

        try {
            $ciphertext = CryptoPrimitives::aesGcmEncrypt($key, $plaintext, $nonce, $aad);
            $json = json_encode([
                'v' => self::VERSION,
                'kid' => $this->activeKeyId,
                'nonce' => base64_encode($nonce),
                'ciphertext' => base64_encode($ciphertext),
            ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
        } catch (\Throwable $error) {
            throw new StorageException('Unable to encrypt secret storage payload.', 0, $error);
        }
        if (!is_string($json) || strlen($json) > self::MAX_ENVELOPE_BYTES) {
            throw new StorageException('Unable to encode secret storage envelope.');
        }
        return $json;
    }

    public function decrypt(string $context, string $envelope): string
    {
        self::assertContext($context);
        if ($envelope === '' || strlen($envelope) > self::MAX_ENVELOPE_BYTES) {
            throw new StorageException('Secret storage envelope is empty or too large.');
        }

        try {
            $decoded = json_decode($envelope, true, 8, JSON_THROW_ON_ERROR);
        } catch (\Throwable $error) {
            throw new StorageException('Secret storage envelope is malformed.', 0, $error);
        }
        if (!is_array($decoded) || array_keys($decoded) !== ['v', 'kid', 'nonce', 'ciphertext']) {
            throw new StorageException('Secret storage envelope has an invalid shape.');
        }
        if (($decoded['v'] ?? null) !== self::VERSION || !is_string($decoded['kid'] ?? null)) {
            throw new StorageException('Secret storage envelope version or key ID is invalid.');
        }
        $keyId = $decoded['kid'];
        self::assertKeyIdForStorage($keyId);
        if (!isset($this->keys[$keyId])) {
            throw new StorageException('The storage key required by this record is unavailable.');
        }
        $nonce = self::decodeCanonicalBase64($decoded['nonce'] ?? null, 12, 12);
        $ciphertext = self::decodeCanonicalBase64(
            $decoded['ciphertext'] ?? null,
            16,
            self::MAX_PLAINTEXT_BYTES + 16
        );
        $key = $this->deriveKey($this->keys[$keyId], $context);

        try {
            return CryptoPrimitives::aesGcmDecrypt($key, $ciphertext, $nonce, $this->aad($keyId, $context));
        } catch (\Throwable $error) {
            throw new StorageException('Secret storage authentication failed.', 0, $error);
        }
    }

    private function deriveKey(string $masterKey, string $context): string
    {
        return CryptoPrimitives::hkdf(
            $masterKey,
            32,
            hash('sha256', "PhpWA\0storage-context\0" . $context, true),
            'PhpWA storage envelope key v1'
        );
    }

    private function aad(string $keyId, string $context): string
    {
        return "PhpWA\0storage-envelope\0v1\0" . $keyId . "\0" . $context;
    }

    private static function assertContext(string $context): void
    {
        if ($context === '' || strlen($context) > 4096) {
            throw new InvalidArgumentException('Storage encryption context is empty or too large.');
        }
    }

    private static function assertKeyId(string $keyId): void
    {
        if (preg_match('/\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/D', $keyId) !== 1) {
            throw new InvalidArgumentException('Storage key ID is invalid.');
        }
    }

    private static function assertKeyIdForStorage(string $keyId): void
    {
        try {
            self::assertKeyId($keyId);
        } catch (InvalidArgumentException $error) {
            throw new StorageException('Secret storage envelope key ID is malformed.', 0, $error);
        }
    }

    private static function decodeCanonicalBase64($value, int $minimum, int $maximum): string
    {
        if (!is_string($value)) {
            throw new StorageException('Secret storage envelope contains malformed base64.');
        }
        $decoded = base64_decode($value, true);
        if (!is_string($decoded) || strlen($decoded) < $minimum || strlen($decoded) > $maximum || base64_encode($decoded) !== $value) {
            throw new StorageException('Secret storage envelope contains malformed base64.');
        }
        return $decoded;
    }
}
