<?php

declare(strict_types=1);

namespace PhpWA;

use InvalidArgumentException;
use LogicException;
use PhpWA\AppState\AppStateSyncClient;
use PhpWA\AppState\AppStateSyncResult;
use PhpWA\Auth\AccountIdentity;
use PhpWA\Auth\ClientPayloadFactory;
use PhpWA\Auth\CredentialsFactory;
use PhpWA\Auth\PairingCodeRequest;
use PhpWA\Auth\PhonePairingCoordinator;
use PhpWA\Client\BlockingQueryExecutor;
use PhpWA\Client\CallProcessor;
use PhpWA\Client\ConnectionSession;
use PhpWA\Client\DirectMessageSender;
use PhpWA\Client\GroupMessageSender;
use PhpWA\Client\HistorySyncWorker;
use PhpWA\Client\HistorySyncRunResult;
use PhpWA\Client\IncomingMessageProcessor;
use PhpWA\Client\NotificationProcessor;
use PhpWA\Client\OnlineClient;
use PhpWA\Client\PreKeyUploadClient;
use PhpWA\Client\ProfilePictureDirectory;
use PhpWA\Client\OutgoingAckProcessor;
use PhpWA\Client\OutgoingMessageResult;
use PhpWA\Client\OutgoingReplayWorker;
use PhpWA\Client\OutgoingRetryReceiptProcessor;
use PhpWA\Client\OutgoingStanzaStore;
use PhpWA\Client\ProtocolConnection;
use PhpWA\Client\ProtocolConnectionInterface;
use PhpWA\Client\ReadReceiptSender;
use PhpWA\Client\ReceiptProcessor;
use PhpWA\Client\SessionEvent;
use PhpWA\Client\SignalIncomingMessageDecryptor;
use PhpWA\Client\StanzaRouter;
use PhpWA\Client\UsyncDeviceDirectory;
use PhpWA\Client\WhatsAppGroupDirectory;
use PhpWA\Client\WhatsAppPreKeyDirectory;
use PhpWA\Config\ClientOptions;
use PhpWA\Exception\AccountBusyException;
use PhpWA\Exception\AccountStatePendingException;
use PhpWA\Exception\ProtocolException;
use PhpWA\Exception\TimeoutException;
use PhpWA\Media\MediaConnectionDirectory;
use PhpWA\Media\MediaCrypto;
use PhpWA\Media\MediaDownloader;
use PhpWA\Media\MediaMessageFactory;
use PhpWA\Media\MediaUploader;
use PhpWA\Protocol\Proto\Message;
use PhpWA\Protocol\Jid\Jid;
use PhpWA\Runtime\AppStateSyncJobWorker;
use PhpWA\Runtime\ClientRunResult;
use PhpWA\Runtime\Deadline;
use PhpWA\Runtime\OutboxHandlerInterface;
use PhpWA\Runtime\OutboxRunResult;
use PhpWA\Runtime\OutboxWorker;
use PhpWA\Runtime\PrivateFileAccountLockProvider;
use PhpWA\Runtime\RunCheckpoint;
use PhpWA\Storage\AccountStoreInterface;
use PhpWA\Storage\AccountTransactionInterface;
use PhpWA\Storage\AesGcmKeyringCipher;
use PhpWA\Storage\CredentialRecord;
use PhpWA\Storage\EncryptedFileAccountStore;
use PhpWA\Storage\ResettableAccountStoreInterface;
use PhpWA\Storage\TransactionalCredentialStore;
use PhpWA\Transport\Noise\NoiseSocket;
use PhpWA\Transport\WebSocket\WebSocketClient;

/**
 * High-level bounded client intended for EasyChat and short cron scripts.
 * Network operations are available only inside withOpenSession().
 */
final class Client
{
    /** @var ClientOptions */ private $options;
    /** @var AccountStoreInterface */ private $accounts;
    /** @var \PhpWA\Runtime\AccountLockProviderInterface */ private $locks;
    /** @var TransactionalCredentialStore */ private $credentials;
    /** @var OutgoingStanzaStore */ private $outgoing;
    /** @var OnlineClient|null */ private $online;
    /** @var list<callable> */ private $sessionHandlers = [];
    /** @var list<callable> */ private $qrHandlers = [];
    /** @var mixed */ private $callbackResult;
    /** @var PhonePairingCoordinator|null */ private $phonePairing;

    public function __construct(ClientOptions $options)
    {
        $this->options = $options;
        $this->accounts = $options->accountStore() ?? new EncryptedFileAccountStore(
            $options->workDirectory() . '/state',
            new AesGcmKeyringCipher('primary', ['primary' => (string) $options->storageKey()])
        );
        $this->locks = $options->lockProvider() ?? new PrivateFileAccountLockProvider($options->workDirectory() . '/locks');
        $this->credentials = new TransactionalCredentialStore($this->accounts);
        $this->outgoing = new OutgoingStanzaStore($this->accounts);
        $this->online = null; $this->phonePairing = null; $this->callbackResult = null;
        $this->credentials->initializeIfMissing($options->accountId(), static function () {
            return (new CredentialsFactory())->create();
        });
    }

    public function accountId(): string { return $this->options->accountId(); }
    public function accountStore(): AccountStoreInterface { return $this->accounts; }

    /**
     * Drops the local Signal ratchet cache after SmaxInvalid (479). WhatsApp
     * may address the same peer through PN and LID users; clearing only one
     * user leaves a stale alternate ratchet. The rejected stanza is already
     * removed from outbox, so rebuilding all sessions from fresh pre-keys is
     * safe and still happens only once for this send attempt.
     */
    public function resetSignalSessionsForTarget(string $target): int
    {
        $jid = Jid::parse($target);
        if (!$jid instanceof Jid || (!$jid->isPhoneNumber() && !$jid->isLid())) {
            throw new InvalidArgumentException('Signal reset target must be a direct phone or LID JID.');
        }
        return $this->accounts->transaction($this->accountId(), static function (AccountTransactionInterface $tx): int {
            $removed = 0;
            foreach ($tx->list('signal_session', 10000) as $key => $stored) {
                $session = Jid::parse((string)$key);
                if (!$session instanceof Jid) { continue; }
                $tx->delete('signal_session', (string)$key, $stored->revision());
                $removed++;
            }
            return $removed;
        });
    }

    public function credentialRecord(): CredentialRecord
    {
        $record = $this->credentials->load($this->accountId());
        if (!$record instanceof CredentialRecord) { throw new ProtocolException('PhpWA credentials are unavailable.'); }
        return $record;
    }

    /** True after pair-success has durably installed the linked identity. */
    public function isPaired(): bool
    {
        $credentials = $this->credentialRecord()->credentials();
        return $credentials->me() instanceof AccountIdentity && $credentials->signedDeviceIdentity() !== null;
    }

    /** True after the paired credentials have completed a reconnect login. */
    public function isReady(): bool
    {
        $credentials = $this->credentialRecord()->credentials();
        return $credentials->me() instanceof AccountIdentity
            && $credentials->signedDeviceIdentity() !== null
            && $credentials->isRegistered();
    }

    public function identity(): ?AccountIdentity
    {
        return $this->credentialRecord()->credentials()->me();
    }

    /**
     * Erases local session state only when no durable incoming event or
     * outgoing stanza remains. This does not unlink the companion remotely;
     * remove it on the phone too.
     */
    public function resetLocalSession(): void
    {
        $this->resetLocalSessionState(false);
    }

    /**
     * Destructive recovery escape hatch. Pending application events and
     * outgoing stanzas are permanently discarded; require explicit operator
     * confirmation before calling this method.
     */
    public function discardLocalSessionAndPendingWork(): void
    {
        $this->resetLocalSessionState(true);
    }

    private function resetLocalSessionState(bool $discardPendingWork): void
    {
        if ($this->online instanceof OnlineClient || $this->phonePairing instanceof PhonePairingCoordinator) {
            throw new LogicException('The local session cannot be reset from an active PhpWA session.');
        }
        if (!$this->accounts instanceof ResettableAccountStoreInterface) {
            throw new LogicException('The configured account store does not support complete account reset.');
        }
        $lock = $this->locks->acquire($this->accountId());
        if ($lock === null) {
            throw new AccountBusyException('Another worker owns this WhatsApp account.');
        }
        try {
            if (!$discardPendingWork) {
                $pendingOutbox = $this->accounts->transaction(
                    $this->accountId(),
                    static function (AccountTransactionInterface $transaction): bool {
                        return $transaction->hasPendingOutbox();
                    }
                );
                if ($pendingOutbox || $this->outgoing->hasPending($this->accountId())) {
                    throw new AccountStatePendingException(
                        'Local session reset was refused because durable work is still pending.'
                    );
                }
            }
            $this->accounts->deleteAccount($this->accountId());
            $this->credentials->initializeIfMissing($this->accountId(), static function () {
                return (new CredentialsFactory())->create();
            });
        } finally {
            $lock->release();
        }
    }

    /** @param callable(SessionEvent,Client):void $handler */
    public function onSessionEvent(callable $handler): self { $this->sessionHandlers[] = $handler; return $this; }
    /** @param callable(list<string>,Client):void $handler */
    public function onQr(callable $handler): self { $this->qrHandlers[] = $handler; return $this; }

    /**
     * Opens a bounded session, invokes the callback exactly once after ONLINE,
     * briefly pumps ACKs, and always closes the transport and account lock.
     *
     * @return mixed callback result
     */
    public function withOpenSession(callable $callback, float $maximumSeconds = 20.0, int $maximumNodes = 500, float $settleSeconds = 0.5)
    {
        if (!is_finite($settleSeconds) || $settleSeconds < 0.0 || $settleSeconds > 10.0) {
            throw new InvalidArgumentException('Session settle duration is invalid.');
        }
        $this->callbackResult = null;
        $result = $this->runInternal($maximumSeconds, $maximumNodes, $callback, $settleSeconds);
        if (!$result->lockAcquired()) { throw new AccountBusyException('Another worker owns this WhatsApp account.'); }
        if (!$result->callbackInvoked()) {
            if ($result->state() === ConnectionSession::ONLINE && $this->outgoing->hasPending($this->accountId())) {
                throw new ProtocolException('Previously staged outgoing messages remain unacknowledged; retry the bounded session.');
            }
            throw new ProtocolException('WhatsApp session did not reach ONLINE before the bounded run ended.');
        }
        return $this->callbackResult;
    }

    public function runFor(float $maximumSeconds = 20.0, int $maximumNodes = 500): ClientRunResult
    {
        return $this->runInternal($maximumSeconds, $maximumNodes, null, 0.0);
    }

    public function consumeOutbox(OutboxHandlerInterface $handler, float $maximumSeconds = 10.0, int $maximumEvents = 100): OutboxRunResult
    {
        if (!is_finite($maximumSeconds) || $maximumSeconds < 0.1 || $maximumSeconds > 300.0) {
            throw new InvalidArgumentException('Outbox run budget is invalid.');
        }
        // The lease must outlive the callback deadline. Otherwise a slow but
        // still in-budget EasyChat transaction could be claimed concurrently
        // by another cron process just before it commits.
        $leaseSeconds = (int) ceil($maximumSeconds) + 5;
        return (new OutboxWorker(
            $this->accountId(), $this->accounts, $handler, null, null, $leaseSeconds
        ))->run(Deadline::after($maximumSeconds), $maximumEvents);
    }

    /** Call from an onQr handler to switch the pending login to phone-code pairing. */
    public function requestPairingCode(string $phoneNumber, ?string $customCode = null, ?Deadline $deadline = null): PairingCodeRequest
    {
        if (!$this->phonePairing instanceof PhonePairingCoordinator) {
            throw new LogicException('A pairing code can be requested only while an authentication session is active.');
        }
        return $this->phonePairing->request($phoneNumber, $this->deadline($deadline), $customCode);
    }

    public function sendText(string $to, string $text, ?Deadline $deadline = null): OutgoingMessageResult
    { return $this->requireOnline()->sendText($to, $text, $this->deadline($deadline)); }
    public function sendTextToPhone(string $phoneNumber, string $text, ?Deadline $deadline = null): OutgoingMessageResult
    { return $this->sendText((string) Jid::fromPhoneNumber($phoneNumber), $text, $deadline); }
    public function sendMessage(string $to, Message $message, ?Deadline $deadline = null): OutgoingMessageResult
    { return $this->requireOnline()->sendMessage($to, $message, $this->deadline($deadline)); }
    public function sendImage(string $to, string $path, string $mimetype, ?string $caption = null, ?Deadline $deadline = null): OutgoingMessageResult
    { return $this->requireOnline()->sendImage($to, $path, $mimetype, $caption, $this->deadline($deadline)); }
    public function sendVideo(string $to, string $path, string $mimetype, ?string $caption = null, ?Deadline $deadline = null): OutgoingMessageResult
    { return $this->requireOnline()->sendVideo($to, $path, $mimetype, $caption, $this->deadline($deadline)); }
    public function sendAudio(string $to, string $path, string $mimetype, bool $voiceNote = false, ?Deadline $deadline = null): OutgoingMessageResult
    { return $this->requireOnline()->sendAudio($to, $path, $mimetype, $voiceNote, $this->deadline($deadline)); }
    public function sendDocument(string $to, string $path, string $mimetype, string $fileName, ?string $caption = null, ?Deadline $deadline = null): OutgoingMessageResult
    { return $this->requireOnline()->sendDocument($to, $path, $mimetype, $fileName, $caption, $this->deadline($deadline)); }
    public function sendSticker(string $to, string $path, string $mimetype = 'image/webp', ?Deadline $deadline = null): OutgoingMessageResult
    { return $this->requireOnline()->sendSticker($to, $path, $mimetype, $this->deadline($deadline)); }
    /** @param list<string> $messageIds */
    public function markRead(array $messageIds, string $chatJid, ?string $participantJid, int $timestamp, ?Deadline $deadline = null): void
    { $this->requireOnline()->markRead($messageIds, $chatJid, $participantJid, $timestamp, $this->deadline($deadline)); }
    /** @param list<string> $messageIds */
    public function markPlayed(array $messageIds, string $chatJid, ?string $participantJid, int $timestamp, ?Deadline $deadline = null): void
    { $this->requireOnline()->markPlayed($messageIds, $chatJid, $participantJid, $timestamp, $this->deadline($deadline)); }

    public function runHistorySync(?Deadline $deadline = null, int $maximumEvents = 50): HistorySyncRunResult
    { return $this->requireOnline()->runHistorySync($this->deadline($deadline), $maximumEvents); }

    public function syncAppState(string $collection, ?Deadline $deadline = null, bool $forceSnapshot = false): AppStateSyncResult
    { return $this->requireOnline()->syncAppState($collection, $this->deadline($deadline), $forceSnapshot); }

    public function profilePictureUrl(string $jid, ?Deadline $deadline = null, bool $preview = true): ?string
    { return $this->requireOnline()->profilePictureUrl($jid, $this->deadline($deadline), $preview); }

    public function downloadMedia(
        string $directPath, string $type, string $mediaKey, string $fileSha256,
        string $fileEncSha256, int $fileLength, string $outputPath, ?Deadline $deadline = null
    ): void {
        $this->requireOnline()->downloadMedia(
            $directPath, $type, $mediaKey, $fileSha256, $fileEncSha256, $fileLength,
            $outputPath, $this->deadline($deadline)
        );
    }

    /**
     * Downloads a `message.received.media` payload without making the
     * application duplicate base64/length validation.
     *
     * @param array<string,mixed> $media
     */
    public function downloadIncomingMedia(array $media, string $outputPath, ?Deadline $deadline = null): void
    {
        foreach (['type', 'direct_path', 'media_key_base64', 'file_sha256_base64', 'file_enc_sha256_base64', 'file_length'] as $key) {
            if (!is_string($media[$key] ?? null) || $media[$key] === '') {
                throw new InvalidArgumentException('Incoming media metadata is incomplete.');
            }
        }
        if (($media['downloadable'] ?? null) !== true
            || preg_match('/\A(?:0|[1-9][0-9]{0,9})\z/D', $media['file_length']) !== 1
            || (float) $media['file_length'] > PHP_INT_MAX) {
            throw new InvalidArgumentException('Incoming media metadata is not marked as safely downloadable.');
        }
        $mediaKey = base64_decode($media['media_key_base64'], true);
        $fileSha = base64_decode($media['file_sha256_base64'], true);
        $fileEncSha = base64_decode($media['file_enc_sha256_base64'], true);
        if (!is_string($mediaKey) || strlen($mediaKey) !== 32 || !is_string($fileSha) || strlen($fileSha) !== 32
            || !is_string($fileEncSha) || strlen($fileEncSha) !== 32) {
            throw new InvalidArgumentException('Incoming media cryptographic metadata is malformed.');
        }
        $this->downloadMedia(
            $media['direct_path'], $media['type'], $mediaKey, $fileSha, $fileEncSha,
            (int) $media['file_length'], $outputPath, $deadline
        );
    }

    private function runInternal(float $maximumSeconds, int $maximumNodes, ?callable $callback, float $settleSeconds): ClientRunResult
    {
        if (!is_finite($maximumSeconds) || $maximumSeconds < 0.1 || $maximumSeconds > 300.0
            || $maximumNodes < 1 || $maximumNodes > 10000) {
            throw new InvalidArgumentException('Client run budget is invalid.');
        }
        $lock = $this->locks->acquire($this->accountId());
        if ($lock === null) { return ClientRunResult::busy(); }
        $deadline = Deadline::after($maximumSeconds); $loopDeadline = $deadline;
        $nodes = 0; $replayed = 0; $events = []; $invoked = false; $router = null; $connection = null;
        $session = null; $phonePairing = null;
        try {
            // Everything after lock acquisition, including potentially failing
            // storage reads, belongs inside this finally boundary. Custom DB
            // lease implementations must never depend on PHP resource cleanup.
            $record = $this->credentialRecord();
            $connection = $this->newConnection();
            $session = new ConnectionSession(
                $this->accountId(), $connection, $this->accounts, $record, null, null, null, $this->options->companion()
            );
            $this->guard(RunCheckpoint::BEFORE_OPEN, $session->state(), $nodes, $loopDeadline);
            $authQueries = new BlockingQueryExecutor(
                $connection,
                function ($node, Deadline $nodeDeadline) use (&$session, &$phonePairing, &$events): void {
                    if (!$session instanceof ConnectionSession || !$phonePairing instanceof PhonePairingCoordinator) {
                        throw new ProtocolException('Authentication router is not initialized.');
                    }
                    if ($phonePairing->process($node, $nodeDeadline)) {
                        $this->refreshSessionCredentials($session, $phonePairing->credentialRecord());
                        $event = new SessionEvent(SessionEvent::PAIRING_CODE_CONFIRMED);
                    } else { $event = $session->process($node, $nodeDeadline); }
                    if ($event->type() !== SessionEvent::IGNORED) { $events[] = $event; $this->emit($event); }
                }
            );
            $phonePairing = new PhonePairingCoordinator(
                $this->accountId(), $connection, $authQueries, $this->accounts, $record,
                null, null, null, null, 160,
                function (CredentialRecord $updated) use (&$session): void {
                    if (!$session instanceof ConnectionSession) { throw new ProtocolException('Authentication session disappeared during pairing.'); }
                    $this->refreshSessionCredentials($session, $updated);
                }
            );
            $this->phonePairing = $phonePairing;
            $connection->open($record->credentials(), $deadline);
            $this->guard(RunCheckpoint::AFTER_OPEN, $session->state(), $nodes, $loopDeadline);
            while ($nodes < $maximumNodes && !$loopDeadline->isExpired() && $connection->isOpen()) {
                $this->guard(RunCheckpoint::BEFORE_RECEIVE, $session->state(), $nodes, $loopDeadline);
                try { $node = $connection->receive($this->receiveDeadline($loopDeadline)); }
                catch (TimeoutException $timeout) {
                    if ($loopDeadline->isExpired() || $this->options->runGuard() === null) { break; }
                    $this->guard(RunCheckpoint::IDLE, $session->state(), $nodes, $loopDeadline);
                    continue;
                }
                // A generation may have changed while the socket was blocked.
                // Check before any received stanza can mutate credentials,
                // Signal ratchets, dedupe state or the durable outbox.
                $this->guard(RunCheckpoint::AFTER_RECEIVE, $session->state(), $nodes, $loopDeadline);
                $nodes++;
                if ($phonePairing->process($node, $loopDeadline)) {
                    $this->refreshSessionCredentials($session, $phonePairing->credentialRecord());
                    $event = new SessionEvent(SessionEvent::PAIRING_CODE_CONFIRMED);
                } else { $event = $session->process($node, $loopDeadline); }
                if ($event->type() !== SessionEvent::IGNORED) {
                    $events[] = $event; $this->emit($event);
                    if ($event->type() === SessionEvent::ONLINE) {
                        $context = $this->onlineContext($connection, $session->credentialRecord());
                        $router = $context['router']; $this->online = $context['online'];
                        $replayed += count((new OutgoingReplayWorker($this->accountId(), $connection, $this->outgoing))->replay($loopDeadline, 100));
                        if ($callback !== null && !$this->outgoing->hasPending($this->accountId())) {
                            $this->guard(RunCheckpoint::BEFORE_CALLBACK, $session->state(), $nodes, $loopDeadline);
                            $this->callbackResult = $callback($this); $invoked = true;
                            if ($settleSeconds <= 0.0) { break; }
                            $loopDeadline = Deadline::at(min($deadline->timestamp(), microtime(true) + $settleSeconds));
                        } elseif ($callback === null) {
                            // Jobs are resumable and bounded; one history batch
                            // and up to twenty app-state pages fit a cron window.
                            $this->guard(RunCheckpoint::BEFORE_MAINTENANCE, $session->state(), $nodes, $loopDeadline);
                            $preKeyResult = $context['prekeys']->runJob($loopDeadline);
                            if ($preKeyResult->hadJob()) {
                                $context = $this->onlineContext($connection, $this->credentialRecord());
                                $router = $context['router']; $this->online = $context['online'];
                            }
                            $context['history']->run($loopDeadline, 50);
                            (new AppStateSyncJobWorker($this->accountId(), $this->accounts, $context['appstate']))
                                ->run($loopDeadline, 5, 20);
                        }
                    }
                    $this->guard(RunCheckpoint::AFTER_NODE, $session->state(), $nodes, $loopDeadline);
                    if ($event->type() === SessionEvent::PAIRING_SUCCESS || $event->type() === SessionEvent::DISCONNECTED) { break; }
                    continue;
                }
                if ($router instanceof StanzaRouter) { $router->route($node, $loopDeadline); }
                $this->guard(RunCheckpoint::AFTER_NODE, $session->state(), $nodes, $loopDeadline);
                if ($callback !== null && !$invoked && $this->online instanceof OnlineClient
                    && !$this->outgoing->hasPending($this->accountId())) {
                    $this->guard(RunCheckpoint::BEFORE_CALLBACK, $session->state(), $nodes, $loopDeadline);
                    $this->callbackResult = $callback($this); $invoked = true;
                    if ($settleSeconds <= 0.0) { break; }
                    $loopDeadline = Deadline::at(min($deadline->timestamp(), microtime(true) + $settleSeconds));
                }
            }
            return new ClientRunResult(true, $nodes, $replayed, $session->state(), $events, $invoked);
        } finally {
            $this->online = null; $this->phonePairing = null;
            try { if ($connection instanceof ProtocolConnectionInterface) { $connection->close(); } }
            finally { $lock->release(); }
        }
    }

    /** @return array{router:StanzaRouter,online:OnlineClient,history:HistorySyncWorker,appstate:AppStateSyncClient,prekeys:PreKeyUploadClient} */
    private function onlineContext(ProtocolConnectionInterface $connection, CredentialRecord $record): array
    {
        $router = null;
        $queries = new BlockingQueryExecutor($connection, static function ($node, Deadline $deadline) use (&$router): void {
            if (!$router instanceof StanzaRouter) { throw new ProtocolException('Online stanza router is not initialized.'); }
            $router->route($node, $deadline);
        });
        $devices = new UsyncDeviceDirectory($queries); $preKeys = new WhatsAppPreKeyDirectory($queries);
        $groups = new WhatsAppGroupDirectory($queries);
        $directSender = new DirectMessageSender($this->accountId(), $connection, $this->accounts, $record, $devices, $preKeys, $this->outgoing);
        $groupSender = new GroupMessageSender($this->accountId(), $connection, $this->accounts, $record, $groups, $devices, $preKeys, $this->outgoing);
        $mediaCrypto = new MediaCrypto(null, $this->options->maximumMediaBytes());
        $mediaDirectory = new MediaConnectionDirectory($queries);
        $uploader = new MediaUploader($mediaCrypto, $mediaDirectory);
        $downloader = new MediaDownloader($mediaCrypto, $mediaDirectory, null, $this->options->maximumMediaBytes() + 26);
        $history = new HistorySyncWorker($this->accountId(), $this->accounts, $this->options->workDirectory() . '/history', $downloader);
        $appState = new AppStateSyncClient($this->accountId(), $this->accounts, $queries, $downloader, $this->options->workDirectory() . '/appstate');
        $preKeyUpload = new PreKeyUploadClient($this->accountId(), $this->accounts, $queries);
        $incoming = new IncomingMessageProcessor(
            $this->accountId(), $connection, $this->accounts, $record, new SignalIncomingMessageDecryptor()
        );
        $router = new StanzaRouter(
            $connection, $incoming,
            new OutgoingRetryReceiptProcessor($this->accountId(), $connection, $this->accounts, $record, $preKeys, $this->outgoing),
            new ReceiptProcessor($this->accountId(), $connection, $this->accounts),
            new OutgoingAckProcessor($this->accountId(), $this->outgoing),
            new CallProcessor($this->accountId(), $connection, $this->accounts),
            new NotificationProcessor($this->accountId(), $connection, $this->accounts)
        );
        $online = new OnlineClient(
            $this->accountId(), $directSender, $groupSender, new ReadReceiptSender($connection),
            $uploader, $downloader, new MediaMessageFactory(), $history, $appState,
            $this->options->workDirectory() . '/media', new ProfilePictureDirectory($queries)
        );
        return ['router' => $router, 'online' => $online, 'history' => $history, 'appstate' => $appState, 'prekeys' => $preKeyUpload];
    }

    private function newConnection(): ProtocolConnectionInterface
    {
        $factory = $this->options->connectionFactory();
        $connection = $factory !== null
            ? $factory()
            : new ProtocolConnection(new NoiseSocket(new WebSocketClient($this->options->webSocket())), new ClientPayloadFactory($this->options->companion()));
        if (!$connection instanceof ProtocolConnectionInterface) { throw new LogicException('Connection factory returned an invalid protocol connection.'); }
        return $connection;
    }

    private function receiveDeadline(Deadline $loopDeadline): Deadline
    {
        if ($this->options->runGuard() === null) { return $loopDeadline; }
        return Deadline::at(min(
            $loopDeadline->timestamp(),
            microtime(true) + $this->options->runGuardInterval()
        ));
    }

    private function guard(string $phase, string $state, int $nodes, Deadline $deadline): void
    {
        $guard = $this->options->runGuard();
        if ($guard === null) { return; }
        $remaining = max(0.0, min(300.0, $deadline->timestamp() - microtime(true)));
        $guard->check(new RunCheckpoint($this->accountId(), $phase, $state, $nodes, $remaining));
    }

    private function emit(SessionEvent $event): void
    {
        foreach ($this->sessionHandlers as $handler) { $handler($event, $this); }
        if ($event->type() === SessionEvent::QR) {
            $codes = $event->data()['codes'] ?? [];
            if (!is_array($codes)) { throw new ProtocolException('QR session event has invalid codes.'); }
            foreach ($this->qrHandlers as $handler) { $handler($codes, $this); }
        }
    }
    private function requireOnline(): OnlineClient
    {
        if (!$this->online instanceof OnlineClient) { throw new LogicException('This operation is available only inside an ONLINE withOpenSession callback.'); }
        return $this->online;
    }
    private function deadline(?Deadline $deadline): Deadline { return $deadline ?? Deadline::after($this->options->operationTimeout()); }
    private function refreshSessionCredentials(ConnectionSession $session, CredentialRecord $record): void
    {
        if ($record->revision() > $session->credentialRecord()->revision()) { $session->refreshCredentialRecord($record); }
        elseif ($record->revision() < $session->credentialRecord()->revision()) {
            throw new ProtocolException('Phone pairing returned stale credentials.');
        }
    }
}
