<?php

declare(strict_types=1);

namespace PhpWA\Client;

use InvalidArgumentException;
use PhpWA\Crypto\PreKeyFactory;
use PhpWA\Crypto\Signal\SignalKeyPair;
use PhpWA\Crypto\Signal\TransactionalSignalStore;
use PhpWA\Exception\ProtocolException;
use PhpWA\Protocol\Binary\BinaryCodec;
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;

/** Crash-replayable upload of this companion's one-time Signal pre-keys. */
final class PreKeyUploadClient
{
    private const BATCHES = 'prekey_upload_batch';
    private const BATCH = 'current';
    /** @var string */ private $accountId;
    /** @var AccountStoreInterface */ private $accounts;
    /** @var QueryExecutorInterface */ private $queries;
    /** @var TransactionalCredentialStore */ private $credentials;
    /** @var PreKeyFactory */ private $factory;
    /** @var MessageIdGeneratorInterface */ private $ids;
    /** @var BinaryCodec */ private $binary;
    /** @var int */ private $batchSize;

    public function __construct(
        string $accountId,
        AccountStoreInterface $accounts,
        QueryExecutorInterface $queries,
        ?PreKeyFactory $factory = null,
        ?MessageIdGeneratorInterface $ids = null,
        ?BinaryCodec $binary = null,
        int $batchSize = 50
    ) {
        if ($accountId === '' || strlen($accountId) > 512 || preg_match('//u', $accountId) !== 1
            || preg_match('/[\x00-\x1f\x7f]/', $accountId) === 1 || $batchSize < 5 || $batchSize > 1000) {
            throw new InvalidArgumentException('Pre-key upload client configuration is invalid.');
        }
        $this->accountId = $accountId; $this->accounts = $accounts; $this->queries = $queries;
        $this->credentials = new TransactionalCredentialStore($accounts); $this->factory = $factory ?? new PreKeyFactory();
        $this->ids = $ids ?? new RandomMessageIdGenerator(); $this->binary = $binary ?? new BinaryCodec(); $this->batchSize = $batchSize;
    }

    public function runJob(Deadline $deadline): PreKeyUploadResult
    {
        $hasJob = $this->accounts->transaction($this->accountId, static function (AccountTransactionInterface $tx): bool {
            return $tx->get('maintenance_job', 'prekey_upload') !== null || $tx->get(self::BATCHES, self::BATCH) !== null;
        });
        if (!$hasJob) { return PreKeyUploadResult::idle(); }
        $batch = $this->loadOrCreateBatch();
        $node = $this->binary->decode($batch['wire']);
        $response = $this->queries->query($node, $deadline);
        if ($response->tag() !== 'iq' || $response->attribute('type') !== 'result') {
            throw new ProtocolException('Pre-key upload returned an invalid IQ result.');
        }
        $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $tx) use ($batch): void {
                $stored = $tx->get(self::BATCHES, self::BATCH);
                if ($stored === null || !hash_equals($stored->value(), $batch['json'])) {
                    throw new ProtocolException('Pre-key upload batch changed while awaiting the server.');
                }
                $record = $this->credentials->loadFromTransaction($tx);
                if (!$record instanceof CredentialRecord) { throw new ProtocolException('Credentials disappeared during pre-key upload.'); }
                $credentials = $record->credentials();
                $first = max($credentials->firstUnuploadedPreKeyId(), $batch['last'] + 1);
                $this->credentials->saveInTransaction(
                    $tx, $credentials->withPreKeyCursor(max($credentials->nextPreKeyId(), $first), $first), $record->revision()
                );
                $tx->delete(self::BATCHES, self::BATCH, $stored->revision());
                $job = $tx->get('maintenance_job', 'prekey_upload');
                if ($job !== null) { $tx->delete('maintenance_job', 'prekey_upload', $job->revision()); }
            }
        );
        return new PreKeyUploadResult(true, $batch['last'] - $batch['first'] + 1, $batch['first'], $batch['last']);
    }

    /** @return array{json:string,wire:string,first:int,last:int} */
    private function loadOrCreateBatch(): array
    {
        return $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $tx): array {
                $stored = $tx->get(self::BATCHES, self::BATCH);
                if ($stored !== null) { return $this->decodeBatch($stored->value()); }
                $record = $this->credentials->loadFromTransaction($tx);
                if (!$record instanceof CredentialRecord) { throw new ProtocolException('Credentials are unavailable for pre-key upload.'); }
                $credentials = $record->credentials();
                if (!$credentials->isRegistered()) { throw new ProtocolException('Pre-keys can be uploaded only after account registration.'); }
                $first = $credentials->firstUnuploadedPreKeyId();
                if ($first > 16777215 - ($this->batchSize - 1)) { throw new ProtocolException('Signal pre-key ID space is exhausted.'); }
                $last = $first + $this->batchSize - 1; $signal = new TransactionalSignalStore($tx, $credentials); $nodes = [];
                for ($id = $first; $id <= $last; $id++) {
                    $key = $signal->loadPreKey($id);
                    if (!$key instanceof SignalKeyPair) {
                        $generated = $this->factory->preKey($id);
                        $key = new SignalKeyPair(PreKeyFactory::signalPublicKey($generated->keyPair()->publicKey()), $generated->keyPair()->privateKey());
                        $signal->storePreKey($id, $key);
                    }
                    $nodes[] = BinaryNode::children('key', [], [
                        BinaryNode::bytes('id', [], $this->bigEndian($id, 3)),
                        BinaryNode::bytes('value', [], $key->publicKey32()),
                    ]);
                }
                $signed = $credentials->signedPreKey();
                $node = BinaryNode::children('iq', [
                    'to' => ServerJid::WHATSAPP, 'type' => 'set', 'xmlns' => 'encrypt', 'id' => $this->ids->generate(),
                ], [
                    BinaryNode::bytes('registration', [], $this->bigEndian($credentials->registrationId(), 4)),
                    BinaryNode::bytes('type', [], "\x05"),
                    BinaryNode::bytes('identity', [], $credentials->signedIdentityKey()->publicKey()),
                    BinaryNode::children('list', [], $nodes),
                    BinaryNode::children('skey', [], [
                        BinaryNode::bytes('id', [], $this->bigEndian($signed->id(), 3)),
                        BinaryNode::bytes('value', [], $signed->keyPair()->publicKey()),
                        BinaryNode::bytes('signature', [], $signed->signature()),
                    ]),
                ]);
                $wire = $this->binary->encode($node);
                $json = $this->json(['v' => 1, 'first' => $first, 'last' => $last, 'wire' => base64_encode($wire)]);
                $tx->put(self::BATCHES, self::BATCH, $json, 0);
                $this->credentials->saveInTransaction(
                    $tx, $credentials->withPreKeyCursor(max($credentials->nextPreKeyId(), $last + 1), $first), $record->revision()
                );
                return ['json' => $json, 'wire' => $wire, 'first' => $first, 'last' => $last];
            }
        );
    }

    /** @return array{json:string,wire:string,first:int,last:int} */
    private function decodeBatch(string $json): array
    {
        try { $value = json_decode($json, true, 8, JSON_THROW_ON_ERROR); }
        catch (\Throwable $error) { throw new ProtocolException('Stored pre-key upload batch is malformed.', 0, $error); }
        if (!is_array($value) || array_keys($value) !== ['v','first','last','wire'] || $value['v'] !== 1
            || !is_int($value['first']) || !is_int($value['last']) || !is_string($value['wire'])
            || $value['first'] < 0 || $value['last'] < $value['first'] || $value['last'] > 16777215
            || $value['last'] - $value['first'] + 1 > 1000) {
            throw new ProtocolException('Stored pre-key upload batch has an invalid shape.');
        }
        $wire = base64_decode($value['wire'], true);
        if (!is_string($wire) || base64_encode($wire) !== $value['wire'] || strlen($wire) > 4194304) {
            throw new ProtocolException('Stored pre-key upload stanza is malformed.');
        }
        return ['json' => $json, 'wire' => $wire, 'first' => $value['first'], 'last' => $value['last']];
    }

    /** @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 pre-key upload batch.', 0, $error); }
        if (!is_string($json) || strlen($json) > 4194304) { throw new ProtocolException('Pre-key upload batch exceeds its limit.'); }
        return $json;
    }
    private function bigEndian(int $value, int $bytes): string
    {
        if ($value < 0 || $bytes < 1 || $bytes > 4 || $value >= 2 ** ($bytes * 8)) { throw new ProtocolException('Pre-key integer exceeds its wire width.'); }
        $result = ''; for ($offset = $bytes - 1; $offset >= 0; $offset--) { $result .= chr(($value >> ($offset * 8)) & 255); }
        return $result;
    }
}
