<?php

declare(strict_types=1);

namespace PhpWA\Auth;

use PhpWA\Crypto\SodiumCompatXEdDsa;
use PhpWA\Crypto\XEdDsaInterface;
use PhpWA\Exception\ProtocolException;
use PhpWA\Protocol\Binary\BinaryNode;
use PhpWA\Protocol\Jid\Jid;
use PhpWA\Protocol\Jid\ServerJid;
use PhpWA\Protocol\Proto\ADVDeviceIdentity;
use PhpWA\Protocol\Proto\ADVEncryptionType;
use PhpWA\Protocol\Proto\ADVSignedDeviceIdentity;
use PhpWA\Protocol\Proto\ADVSignedDeviceIdentityHMAC;
use PhpWA\Protocol\ProtobufCodec;

final class PairingSuccessProcessor implements PairingProcessorInterface
{
    private const ACCOUNT_SIGNATURE_PREFIX = "\x06\x00";
    private const DEVICE_SIGNATURE_PREFIX = "\x06\x01";
    private const HOSTED_ACCOUNT_SIGNATURE_PREFIX = "\x06\x05";
    private const MAX_IDENTITY_BYTES = 1048576;

    /** @var XEdDsaInterface */ private $xeddsa;
    /** @var ProtobufCodec */ private $protobuf;

    public function __construct(?XEdDsaInterface $xeddsa = null)
    {
        $this->xeddsa = $xeddsa ?? new SodiumCompatXEdDsa();
        $this->protobuf = new ProtobufCodec(self::MAX_IDENTITY_BYTES);
    }

    public function process(BinaryNode $stanza, AuthenticationCredentials $credentials): PairingResult
    {
        try {
            if ($stanza->tag() !== 'iq' || $stanza->attribute('from') !== ServerJid::WHATSAPP) {
                throw new ProtocolException('Pair-success stanza has an invalid source.');
            }
            $messageId = $this->identifier($stanza->attribute('id'), 'pair-success message ID');
            $pairSuccess = $stanza->child('pair-success');
            $deviceIdentityNode = $pairSuccess instanceof BinaryNode ? $pairSuccess->child('device-identity') : null;
            $deviceNode = $pairSuccess instanceof BinaryNode ? $pairSuccess->child('device') : null;
            if (!$pairSuccess instanceof BinaryNode || !$deviceIdentityNode instanceof BinaryNode || !$deviceNode instanceof BinaryNode) {
                throw new ProtocolException('Pair-success stanza is missing required children.');
            }
            $containerBytes = $deviceIdentityNode->bytesContent();
            if (!is_string($containerBytes) || $containerBytes === '' || strlen($containerBytes) > self::MAX_IDENTITY_BYTES) {
                throw new ProtocolException('Pair-success device identity has invalid content.');
            }

            /** @var ADVSignedDeviceIdentityHMAC $container */
            $container = $this->protobuf->decode(ADVSignedDeviceIdentityHMAC::class, $containerBytes);
            $details = $container->getDetails();
            $hmac = $container->getHmac();
            $containerType = $container->hasAccountType() ? $container->getAccountType() : ADVEncryptionType::E2EE;
            $this->encryptionType($containerType);
            if ($details === '' || strlen($details) > self::MAX_IDENTITY_BYTES || strlen($hmac) !== 32) {
                throw new ProtocolException('Pair-success identity HMAC envelope is malformed.');
            }
            $hmacPrefix = $containerType === ADVEncryptionType::HOSTED ? self::HOSTED_ACCOUNT_SIGNATURE_PREFIX : '';
            $expectedHmac = hash_hmac('sha256', $hmacPrefix . $details, $credentials->advSecretKey(), true);
            if (!hash_equals($expectedHmac, $hmac)) {
                throw new ProtocolException('Pair-success identity authentication failed.');
            }

            /** @var ADVSignedDeviceIdentity $account */
            $account = $this->protobuf->decode(ADVSignedDeviceIdentity::class, $details);
            $deviceDetails = $account->getDetails();
            $accountSignatureKey = $account->getAccountSignatureKey();
            $accountSignature = $account->getAccountSignature();
            if ($deviceDetails === '' || strlen($deviceDetails) > 65536
                || strlen($accountSignatureKey) !== 32 || strlen($accountSignature) !== 64) {
                throw new ProtocolException('Pair-success signed identity is malformed.');
            }

            /** @var ADVDeviceIdentity $deviceIdentity */
            $deviceIdentity = $this->protobuf->decode(ADVDeviceIdentity::class, $deviceDetails);
            if (!$deviceIdentity->hasKeyIndex()) {
                throw new ProtocolException('Pair-success device identity has no key index.');
            }
            $deviceType = $deviceIdentity->hasDeviceType() ? $deviceIdentity->getDeviceType() : ADVEncryptionType::E2EE;
            $this->encryptionType($deviceType);
            if ($deviceIdentity->hasAccountType()) {
                $this->encryptionType($deviceIdentity->getAccountType());
            }
            $accountPrefix = $deviceType === ADVEncryptionType::HOSTED
                ? self::HOSTED_ACCOUNT_SIGNATURE_PREFIX
                : self::ACCOUNT_SIGNATURE_PREFIX;
            $accountMessage = $accountPrefix . $deviceDetails . $credentials->signedIdentityKey()->publicKey();
            if (!$this->xeddsa->verify($accountSignatureKey, $accountMessage, $accountSignature)) {
                throw new ProtocolException('Pair-success account signature verification failed.');
            }

            $deviceMessage = self::DEVICE_SIGNATURE_PREFIX
                . $deviceDetails
                . $credentials->signedIdentityKey()->publicKey()
                . $accountSignatureKey;
            $account->setDeviceSignature($this->xeddsa->sign(
                $credentials->signedIdentityKey()->privateKey(),
                $deviceMessage
            ));
            $storedAccount = $account->serializeToString();

            $jidText = $this->identifier($deviceNode->attribute('jid'), 'paired account JID');
            $lidText = $this->identifier($deviceNode->attribute('lid'), 'paired account LID');
            $jid = Jid::parse($jidText);
            $lid = Jid::parse($lidText);
            if (!$jid instanceof Jid || (!$jid->isPhoneNumber() && !$jid->isHostedPhoneNumber())
                || preg_match('/^[1-9][0-9]{0,19}$/D', $jid->user()) !== 1
                || !$lid instanceof Jid || (!$lid->isLid() && !$lid->isHostedLid())
                || preg_match('/^[1-9][0-9]{0,19}$/D', $lid->user()) !== 1) {
                throw new ProtocolException('Pair-success returned invalid account identifiers.');
            }
            $business = $pairSuccess->child('biz');
            $platformNode = $pairSuccess->child('platform');
            $businessName = $business instanceof BinaryNode ? $business->attribute('name') : null;
            $platform = $platformNode instanceof BinaryNode ? $platformNode->attribute('name') : null;
            $identity = new AccountIdentity($jidText, $this->optionalText($businessName, 512), $lidText);
            $signalIdentity = SignalIdentity::fromAccountSignatureKey($lidText, $accountSignatureKey);
            $updatedCredentials = $credentials->withPairing(
                $identity,
                $storedAccount,
                $signalIdentity,
                $this->optionalText($platform, 1024)
            );

            $replyAccount = clone $account;
            $replyAccount->clearAccountSignatureKey();
            $keyIndex = $deviceIdentity->getKeyIndex();
            $reply = BinaryNode::children('iq', [
                'to' => ServerJid::WHATSAPP,
                'type' => 'result',
                'id' => $messageId,
            ], [
                BinaryNode::children('pair-device-sign', [], [
                    BinaryNode::bytes('device-identity', ['key-index' => (string) $keyIndex], $replyAccount->serializeToString()),
                ]),
            ]);
            return new PairingResult($updatedCredentials, $reply, $keyIndex);
        } catch (ProtocolException $error) {
            throw $error;
        } catch (\Throwable $error) {
            throw new ProtocolException('Unable to validate WhatsApp pair-success response.', 0, $error);
        }
    }

    private function encryptionType(int $type): void
    {
        if ($type !== ADVEncryptionType::E2EE && $type !== ADVEncryptionType::HOSTED) {
            throw new ProtocolException('Pair-success contains an unknown encryption type.');
        }
    }

    private function identifier(?string $value, string $field): string
    {
        if (!is_string($value) || $value === '' || strlen($value) > 256 || preg_match('//u', $value) !== 1
            || preg_match('/[,\x00-\x1f\x7f]/', $value) === 1) {
            throw new ProtocolException('Invalid ' . $field . '.');
        }
        return $value;
    }

    private function optionalText(?string $value, int $maxBytes): ?string
    {
        if ($value === null || $value === '') { return null; }
        if (strlen($value) > $maxBytes || preg_match('//u', $value) !== 1
            || preg_match('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', $value) === 1) {
            throw new ProtocolException('Pair-success text attribute is invalid.');
        }
        return $value;
    }
}
