<?php

declare(strict_types=1);

namespace PhpWA\Auth;

use InvalidArgumentException;
use PhpWA\Client\MessageIdGeneratorInterface;
use PhpWA\Client\QueryExecutorInterface;
use PhpWA\Client\RandomMessageIdGenerator;
use PhpWA\Client\ProtocolConnectionInterface;
use PhpWA\Crypto\KeyPair;
use PhpWA\Crypto\RandomSourceInterface;
use PhpWA\Crypto\SystemRandomSource;
use PhpWA\Crypto\X25519;
use PhpWA\Exception\ProtocolException;
use PhpWA\Protocol\Binary\BinaryNode;
use PhpWA\Protocol\Jid\ServerJid;
use PhpWA\Runtime\Deadline;
use PhpWA\Storage\AccountStoreInterface;
use PhpWA\Storage\AccountTransactionInterface;
use PhpWA\Storage\CredentialRecord;
use PhpWA\Storage\TransactionalCredentialStore;

/** Durable eight-character phone pairing-code handshake. */
final class PhonePairingCoordinator
{
    private const COLLECTION = 'phone_pairing';
    private const KEY = 'pending';
    private const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTVWXYZ';

    /** @var string */ private $accountId;
    /** @var ProtocolConnectionInterface */ private $connection;
    /** @var QueryExecutorInterface */ private $queries;
    /** @var AccountStoreInterface */ private $accounts;
    /** @var TransactionalCredentialStore */ private $credentials;
    /** @var CredentialRecord */ private $record;
    /** @var RandomSourceInterface */ private $random;
    /** @var MessageIdGeneratorInterface */ private $ids;
    /** @var PairingCodeCrypto */ private $crypto;
    /** @var callable */ private $clock;
    /** @var int */ private $lifetimeSeconds;
    /** @var callable|null */ private $credentialListener;

    public function __construct(
        string $accountId,
        ProtocolConnectionInterface $connection,
        QueryExecutorInterface $queries,
        AccountStoreInterface $accounts,
        CredentialRecord $record,
        ?RandomSourceInterface $random = null,
        ?MessageIdGeneratorInterface $ids = null,
        ?PairingCodeCrypto $crypto = null,
        ?callable $clock = null,
        int $lifetimeSeconds = 160,
        ?callable $credentialListener = null
    ) {
        if ($accountId === '' || strlen($accountId) > 512 || preg_match('//u', $accountId) !== 1
            || preg_match('/[\x00-\x1f\x7f]/', $accountId) === 1 || $lifetimeSeconds < 30 || $lifetimeSeconds > 600) {
            throw new InvalidArgumentException('Phone pairing coordinator configuration is invalid.');
        }
        $this->accountId = $accountId; $this->connection = $connection; $this->queries = $queries; $this->accounts = $accounts;
        $this->credentials = new TransactionalCredentialStore($accounts); $this->record = $record;
        $this->random = $random ?? new SystemRandomSource(); $this->ids = $ids ?? new RandomMessageIdGenerator();
        $this->crypto = $crypto ?? new PairingCodeCrypto(); $this->clock = $clock ?? static function (): int { return time(); };
        $this->lifetimeSeconds = $lifetimeSeconds; $this->credentialListener = $credentialListener;
    }

    public function request(
        string $phoneNumber,
        Deadline $deadline,
        ?string $customCode = null,
        bool $showPushNotification = true,
        string $clientType = '1',
        string $displayName = 'Chrome (Mac OS)'
    ): PairingCodeRequest {
        if ($this->record->credentials()->me() !== null) { throw new ProtocolException('Phone pairing is available only for an unpaired account.'); }
        $phone = $this->phone($phoneNumber); $code = $customCode === null ? $this->generateCode() : $this->code($customCode);
        if (preg_match('/\A[0-9a-z]\z/D', $clientType) !== 1 || strlen($displayName) < 3 || strlen($displayName) > 128
            || preg_match('/\A[A-Za-z0-9 ._()\/-]+\z/D', $displayName) !== 1) {
            throw new InvalidArgumentException('Pairing client identity is invalid.');
        }
        $ephemeral = X25519::generate($this->random); $salt = $this->bytes(32); $iv = $this->bytes(16);
        $wrapped = $this->crypto->wrapCompanionPublic($code, $salt, $iv, $ephemeral->publicKey());
        $request = BinaryNode::children('iq', [
            'to' => ServerJid::WHATSAPP, 'type' => 'set', 'xmlns' => 'md', 'id' => $this->ids->generate(),
        ], [BinaryNode::children('link_code_companion_reg', [
            'jid' => $phone . '@s.whatsapp.net', 'stage' => 'companion_hello',
            'should_show_push_notification' => $showPushNotification ? 'true' : 'false',
        ], [
            BinaryNode::bytes('link_code_pairing_wrapped_companion_ephemeral_pub', [], $wrapped),
            BinaryNode::bytes('companion_server_auth_key_pub', [], $this->record->credentials()->noiseKey()->publicKey()),
            BinaryNode::bytes('companion_platform_id', [], $clientType),
            BinaryNode::bytes('companion_platform_display', [], $displayName),
            BinaryNode::bytes('link_code_pairing_nonce', [], "\0"),
        ])]);
        $response = $this->queries->query($request, $deadline);
        $container = $response->child('link_code_companion_reg');
        $refNode = $container instanceof BinaryNode ? $container->child('link_code_pairing_ref') : null;
        $ref = $refNode instanceof BinaryNode ? $refNode->bytesContent() : null;
        if (!is_string($ref) || $ref === '' || strlen($ref) > 1024) {
            throw new ProtocolException('Phone pairing response omitted its bounded pairing reference.');
        }
        $now = $this->now(); $expiresAt = $this->add($now, $this->lifetimeSeconds);
        $pending = [
            'v' => 1, 'phone' => $phone, 'code' => $code,
            'ephemeral_private' => base64_encode($ephemeral->privateKey()),
            'ephemeral_public' => base64_encode($ephemeral->publicKey()),
            'ref' => base64_encode($ref), 'created_at' => $now, 'expires_at' => $expiresAt,
            'phase' => 'awaiting_primary', 'wrapped_key_bundle' => null,
        ];
        $this->accounts->transaction($this->accountId, function (AccountTransactionInterface $tx) use ($pending): void {
            $known = $tx->get(self::COLLECTION, self::KEY);
            $tx->put(self::COLLECTION, self::KEY, $this->json($pending), $known === null ? 0 : $known->revision());
        });
        return new PairingCodeRequest(substr($code, 0, 4) . '-' . substr($code, 4), $expiresAt);
    }

    /** Returns true only for a link-code registration notification. */
    public function process(BinaryNode $notification, Deadline $deadline): bool
    {
        if ($notification->tag() !== 'notification' || $notification->attribute('type') !== 'link_code_companion_reg') { return false; }
        $id = $this->identifier($notification->attribute('id'), 'pairing notification ID');
        $from = $this->identifier($notification->attribute('from'), 'pairing notification source');
        $node = $notification->child('link_code_companion_reg');
        if (!$node instanceof BinaryNode) { throw new ProtocolException('Phone pairing notification has no registration payload.'); }
        $pending = $this->loadPending();
        if ($pending['expires_at'] < $this->now()) { throw new ProtocolException('Phone pairing code has expired.'); }
        $ref = $this->bytesChild($node, 'link_code_pairing_ref', 1, 1024);
        if (!hash_equals($this->b64($pending['ref'], 1, 1024, 'pairing reference'), $ref)) {
            throw new ProtocolException('Phone pairing notification reference does not match the pending request.');
        }
        if ($pending['phase'] === 'awaiting_primary') {
            $wrappedPrimary = $this->bytesChild($node, 'link_code_pairing_wrapped_primary_ephemeral_pub', 80, 80);
            $primaryIdentity = $this->bytesChild($node, 'primary_identity_pub', 32, 32);
            $ephemeral = new KeyPair(
                $this->b64($pending['ephemeral_private'], 32, 32, 'pairing ephemeral private key'),
                $this->b64($pending['ephemeral_public'], 32, 32, 'pairing ephemeral public key')
            );
            $material = $this->crypto->finish(
                $pending['code'], $ephemeral, $this->record->credentials()->signedIdentityKey(),
                $wrappedPrimary, $primaryIdentity, $this->bytes(32), $this->bytes(32), $this->bytes(12)
            );
            $pending['phase'] = 'finish_ready'; $pending['wrapped_key_bundle'] = base64_encode($material->wrappedKeyBundle());
            $this->record = $this->accounts->transaction(
                $this->accountId,
                function (AccountTransactionInterface $tx) use ($pending, $material): CredentialRecord {
                    $current = $this->credentials->loadFromTransaction($tx);
                    if (!$current instanceof CredentialRecord || $current->revision() !== $this->record->revision()) {
                        throw new ProtocolException('Credentials changed during phone pairing.');
                    }
                    $stored = $tx->get(self::COLLECTION, self::KEY);
                    if ($stored === null) { throw new ProtocolException('Phone pairing state disappeared before finish.'); }
                    $saved = $this->credentials->saveInTransaction(
                        $tx, $current->credentials()->withAdvSecretKey($material->advSecretKey()), $current->revision()
                    );
                    $tx->put(self::COLLECTION, self::KEY, $this->json($pending), $stored->revision());
                    return $saved;
                }
            );
            if ($this->credentialListener !== null) { ($this->credentialListener)($this->record); }
        }
        $bundle = $this->b64($pending['wrapped_key_bundle'], 156, 156, 'wrapped pairing key bundle');
        $finish = BinaryNode::children('iq', [
            'to' => ServerJid::WHATSAPP, 'type' => 'set', 'xmlns' => 'md', 'id' => $this->ids->generate(),
        ], [BinaryNode::children('link_code_companion_reg', [
            'jid' => $pending['phone'] . '@s.whatsapp.net', 'stage' => 'companion_finish',
        ], [
            BinaryNode::bytes('link_code_pairing_wrapped_key_bundle', [], $bundle),
            BinaryNode::bytes('companion_identity_public', [], $this->record->credentials()->signedIdentityKey()->publicKey()),
            BinaryNode::bytes('link_code_pairing_ref', [], $ref),
        ])]);
        $this->queries->query($finish, $deadline);
        $this->accounts->transaction($this->accountId, function (AccountTransactionInterface $tx) use ($ref): void {
            $stored = $tx->get(self::COLLECTION, self::KEY); if ($stored === null) { return; }
            $state = $this->decode($stored->value());
            if (hash_equals($this->b64($state['ref'], 1, 1024, 'pairing reference'), $ref)) {
                $tx->delete(self::COLLECTION, self::KEY, $stored->revision());
            }
        });
        $this->connection->send(new BinaryNode('ack', [
            'class' => 'notification', 'id' => $id, 'to' => $from, 'type' => 'link_code_companion_reg',
        ]), $deadline);
        return true;
    }

    public function credentialRecord(): CredentialRecord { return $this->record; }

    /** @return array<string,mixed> */
    private function loadPending(): array
    {
        $stored = $this->accounts->transaction($this->accountId, static function (AccountTransactionInterface $tx) { return $tx->get(self::COLLECTION, self::KEY); });
        if ($stored === null) { throw new ProtocolException('Phone pairing notification arrived without a pending request.'); }
        return $this->decode($stored->value());
    }
    /** @return array<string,mixed> */
    private function decode(string $json): array
    {
        try { $value = json_decode($json, true, 16, JSON_THROW_ON_ERROR); }
        catch (\Throwable $error) { throw new ProtocolException('Stored phone pairing state is malformed.', 0, $error); }
        $keys = ['v','phone','code','ephemeral_private','ephemeral_public','ref','created_at','expires_at','phase','wrapped_key_bundle'];
        if (!is_array($value) || array_keys($value) !== $keys || $value['v'] !== 1
            || !is_string($value['phone']) || !is_string($value['code']) || !is_string($value['ephemeral_private'])
            || !is_string($value['ephemeral_public']) || !is_string($value['ref']) || !is_int($value['created_at'])
            || !is_int($value['expires_at']) || !is_string($value['phase'])
            || ($value['wrapped_key_bundle'] !== null && !is_string($value['wrapped_key_bundle']))) {
            throw new ProtocolException('Stored phone pairing state has an invalid shape.');
        }
        $this->phone($value['phone']); $this->code($value['code']);
        if ($value['created_at'] < 0 || $value['expires_at'] < $value['created_at']
            || !in_array($value['phase'], ['awaiting_primary', 'finish_ready'], true)
            || ($value['phase'] === 'awaiting_primary') !== ($value['wrapped_key_bundle'] === null)) {
            throw new ProtocolException('Stored phone pairing phase is invalid.');
        }
        return $value;
    }
    /** @param array<string,mixed> $value */
    private function json(array $value): string
    {
        try { $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); }
        catch (\Throwable $error) { throw new ProtocolException('Unable to serialize phone pairing state.', 0, $error); }
        if (!is_string($json) || strlen($json) > 16384) { throw new ProtocolException('Phone pairing state exceeds its limit.'); }
        return $json;
    }
    private function phone(string $value): string
    {
        if (preg_match('/\A[+0-9 ()-]{7,40}\z/D', $value) !== 1) { throw new InvalidArgumentException('Phone number contains unsupported characters.'); }
        $phone = preg_replace('/[^0-9]/', '', $value);
        if (!is_string($phone) || preg_match('/\A[1-9][0-9]{6,19}\z/D', $phone) !== 1) {
            throw new InvalidArgumentException('Phone number must be in international format.');
        }
        return $phone;
    }
    private function code(string $value): string
    {
        $code = strtoupper(str_replace('-', '', $value));
        if (preg_match('/\A[' . self::ALPHABET . ']{8}\z/D', $code) !== 1) {
            throw new InvalidArgumentException('Custom pairing code must contain exactly eight Crockford characters.');
        }
        return $code;
    }
    private function generateCode(): string
    {
        $bytes = $this->bytes(5); $bits = 0; $buffer = 0; $result = '';
        for ($index = 0; $index < 5; $index++) {
            $buffer = ($buffer << 8) | ord($bytes[$index]); $bits += 8;
            while ($bits >= 5) { $bits -= 5; $result .= self::ALPHABET[($buffer >> $bits) & 31]; }
        }
        if (strlen($result) !== 8) { throw new ProtocolException('Pairing code generation failed.'); }
        return $result;
    }
    private function bytes(int $length): string
    {
        $value = $this->random->bytes($length);
        if (strlen($value) !== $length) { throw new ProtocolException('Pairing random source returned an invalid length.'); }
        return $value;
    }
    private function bytesChild(BinaryNode $node, string $tag, int $minimum, int $maximum): string
    {
        $child = $node->child($tag); $bytes = $child instanceof BinaryNode ? $child->bytesContent() : null;
        if (!is_string($bytes) || strlen($bytes) < $minimum || strlen($bytes) > $maximum) {
            throw new ProtocolException('Phone pairing ' . $tag . ' field is missing or malformed.');
        }
        return $bytes;
    }
    private function b64($value, int $minimum, int $maximum, string $name): string
    {
        $decoded = is_string($value) ? base64_decode($value, true) : false;
        if (!is_string($decoded) || strlen($decoded) < $minimum || strlen($decoded) > $maximum || base64_encode($decoded) !== $value) {
            throw new ProtocolException('Stored ' . $name . ' is malformed.');
        }
        return $decoded;
    }
    private function identifier(?string $value, string $name): string
    {
        if ($value === null || $value === '' || strlen($value) > 512 || preg_match('//u', $value) !== 1
            || preg_match('/[\x00-\x1f\x7f]/', $value) === 1) { throw new ProtocolException(ucfirst($name) . ' is invalid.'); }
        return $value;
    }
    private function now(): int
    {
        $now = ($this->clock)(); if (!is_int($now) || $now < 0) { throw new ProtocolException('Phone pairing clock returned an invalid timestamp.'); }
        return $now;
    }
    private function add(int $value, int $increment): int { return $value > PHP_INT_MAX - $increment ? PHP_INT_MAX : $value + $increment; }
}
