<?php

declare(strict_types=1);

namespace PhpWA\Client;

use InvalidArgumentException;
use PhpWA\Auth\AccountIdentity;
use PhpWA\Auth\AuthenticationCredentials;
use PhpWA\Auth\CompanionDeviceConfig;
use PhpWA\Auth\PairingCommitter;
use PhpWA\Auth\PairingProcessorInterface;
use PhpWA\Auth\PairingSuccessProcessor;
use PhpWA\Auth\QrPayloadBuilder;
use PhpWA\Exception\ProtocolException;
use PhpWA\Protocol\Binary\BinaryNode;
use PhpWA\Protocol\Jid\Jid;
use PhpWA\Protocol\Jid\ServerJid;
use PhpWA\Runtime\Deadline;
use PhpWA\Storage\AccountStoreInterface;
use PhpWA\Storage\CredentialRecord;
use PhpWA\Storage\TransactionalCredentialStore;

 
final class ConnectionSession
{
    public const AWAITING_AUTH = 'awaiting_auth';
    public const PAIRING = 'pairing';
    public const AWAITING_RESTART = 'awaiting_restart';
    public const ONLINE = 'online';
    public const CLOSED = 'closed';
    public const FAILED = 'failed';

      private $accountId;
      private $connection;
      private $accounts;
      private $credentialStore;
      private $record;
      private $pairingProcessor;
      private $pairingCommitter;
      private $qr;
      private $config;
      private $disconnects;
      private $state = self::AWAITING_AUTH;

    public function __construct(
        string $accountId,
        ProtocolConnectionInterface $connection,
        AccountStoreInterface $accounts,
        CredentialRecord $record,
        ?PairingProcessorInterface $pairingProcessor = null,
        ?PairingCommitter $pairingCommitter = null,
        ?QrPayloadBuilder $qr = null,
        ?CompanionDeviceConfig $config = null,
        ?DisconnectClassifier $disconnects = null
    ) {
        if ($accountId === '' || strlen($accountId) > 512 || preg_match('//u', $accountId) !== 1
            || preg_match('/[\x00-\x1f\x7f]/', $accountId) === 1) {
            throw new InvalidArgumentException('Account ID is invalid.');
        }
        $this->accountId = $accountId;
        $this->connection = $connection;
        $this->accounts = $accounts;
        $this->credentialStore = new TransactionalCredentialStore($accounts);
        $this->record = $record;
        $this->pairingProcessor = $pairingProcessor ?? new PairingSuccessProcessor();
        $this->pairingCommitter = $pairingCommitter ?? new PairingCommitter($accounts, $this->credentialStore);
        $this->qr = $qr ?? new QrPayloadBuilder();
        $this->config = $config ?? new CompanionDeviceConfig();
        $this->disconnects = $disconnects ?? new DisconnectClassifier();
    }

    public function process(BinaryNode $node, Deadline $deadline): SessionEvent
    {
        try {
            if ($node->tag() === 'iq' && $node->child('pair-device') instanceof BinaryNode) {
                return $this->pairDevice($node, $deadline);
            }
            if ($node->tag() === 'iq' && $node->child('pair-success') instanceof BinaryNode) {
                return $this->pairSuccess($node, $deadline);
            }
            if ($node->tag() === 'success') { return $this->success($node); }
            if ($node->tag() === 'stream:error') { return $this->disconnect($this->streamDecision($node)); }
            if ($node->tag() === 'failure') { return $this->disconnect($this->failureDecision($node)); }
            if ($node->tag() === 'ib') { return $this->ib($node, $deadline); }
            return new SessionEvent(SessionEvent::IGNORED, ['tag' => $node->tag()]);
        } catch (\Throwable $error) {
            if ($this->state !== self::CLOSED) { $this->state = self::FAILED; }
            throw $error;
        }
    }

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

     
    public function refreshCredentialRecord(CredentialRecord $record): void
    {
        if ($this->state !== self::AWAITING_AUTH && $this->state !== self::PAIRING) {
            throw new ProtocolException('Credentials cannot be refreshed in the current authentication state.');
        }
        if ($record->revision() <= $this->record->revision()) {
            throw new ProtocolException('Credential refresh must advance the durable revision.');
        }
        $this->record = $record;
    }

    private function pairDevice(BinaryNode $stanza, Deadline $deadline): SessionEvent
    {
        if ($this->state !== self::AWAITING_AUTH && $this->state !== self::PAIRING) {
            throw new ProtocolException('pair-device is invalid in the current authentication state.');
        }
        $id = $this->serverIqId($stanza, 'set');
        $this->connection->send(new BinaryNode('iq', [
            'to' => ServerJid::WHATSAPP, 'type' => 'result', 'id' => $id,
        ]), $deadline);
        $pairDevice = $stanza->child('pair-device');
        $refs = $pairDevice instanceof BinaryNode ? $pairDevice->childrenNamed('ref') : [];
        if ($refs === [] || count($refs) > 16 || count($refs) !== count($pairDevice->childNodes())) {
            throw new ProtocolException('pair-device contains an invalid QR reference set.');
        }
        $codes = [];
        foreach ($refs as $ref) {
            $bytes = $ref->bytesContent();
            if (!is_string($bytes)) { throw new ProtocolException('QR reference must be binary content.'); }
            try {
                $codes[] = $this->qr->build($bytes, $this->record->credentials(), $this->config);
            } catch (\Throwable $error) {
                throw new ProtocolException('pair-device contains an invalid QR reference.', 0, $error);
            }
        }
        $this->state = self::PAIRING;
        return new SessionEvent(SessionEvent::QR, ['codes' => $codes]);
    }

    private function pairSuccess(BinaryNode $stanza, Deadline $deadline): SessionEvent
    {
        if ($this->state !== self::AWAITING_AUTH && $this->state !== self::PAIRING) {
            throw new ProtocolException('pair-success is invalid in the current authentication state.');
        }
        $result = $this->pairingProcessor->process($stanza, $this->record->credentials());
         
         
        $this->record = $this->pairingCommitter->commit(
            $this->accountId,
            $result,
            $this->record->revision()
        );
        $this->connection->send($result->reply(), $deadline);
        $this->state = self::AWAITING_RESTART;
        $identity = $this->record->credentials()->me();
        return new SessionEvent(SessionEvent::PAIRING_SUCCESS, [
            'jid' => $identity instanceof AccountIdentity ? $identity->id() : null,
            'lid' => $identity instanceof AccountIdentity ? $identity->lid() : null,
            'key_index' => $result->keyIndex(),
        ], true);
    }

    private function success(BinaryNode $node): SessionEvent
    {
        if ($this->state !== self::AWAITING_AUTH) {
            throw new ProtocolException('Connection success is invalid in the current authentication state.');
        }
        $credentials = $this->record->credentials();
        $identity = $credentials->me();
        if (!$identity instanceof AccountIdentity) {
            throw new ProtocolException('Connection success was received before account pairing.');
        }
        $lid = $node->attribute('lid');
        if ($lid !== null) {
            $parsed = Jid::parse($lid);
            if (!$parsed instanceof Jid || (!$parsed->isLid() && !$parsed->isHostedLid())
                || preg_match('/^[1-9][0-9]{0,19}$/D', $parsed->user()) !== 1) {
                throw new ProtocolException('Connection success contains an invalid LID.');
            }
            if ($identity->lid() !== null && !hash_equals($identity->lid(), $lid)) {
                throw new ProtocolException('Connection success LID conflicts with the pinned account identity.');
            }
        }
        $updated = $credentials->withRegistered(true);
        $this->record = $this->credentialStore->save($this->accountId, $updated, $this->record->revision());
        $this->state = self::ONLINE;
        return new SessionEvent(SessionEvent::ONLINE, [
            'jid' => $identity->id(), 'lid' => $identity->lid(),
        ]);
    }

    private function ib(BinaryNode $node, Deadline $deadline): SessionEvent
    {
        $routing = $node->child('edge_routing');
        if ($routing instanceof BinaryNode) {
            $info = $routing->child('routing_info');
            $bytes = $info instanceof BinaryNode ? $info->bytesContent() : null;
            if (!is_string($bytes) || $bytes === '') {
                throw new ProtocolException('edge_routing has no routing_info payload.');
            }
            $updated = $this->record->credentials()->withRoutingInfo($bytes);
            $this->record = $this->credentialStore->save($this->accountId, $updated, $this->record->revision());
            return new SessionEvent(SessionEvent::ROUTING_UPDATED);
        }
        $preview = $node->child('offline_preview');
        if ($preview instanceof BinaryNode) {
            $this->connection->send(BinaryNode::children('ib', [], [
                new BinaryNode('offline_batch', ['count' => '100']),
            ]), $deadline);
            return new SessionEvent(SessionEvent::OFFLINE_PREVIEW, $this->offlineCounts($preview));
        }
        $offline = $node->child('offline');
        if ($offline instanceof BinaryNode) {
            return new SessionEvent(SessionEvent::OFFLINE_COMPLETE, ['count' => $this->count($offline->attribute('count'))]);
        }
        if ($node->child('downgrade_webclient') instanceof BinaryNode) {
            return $this->disconnect($this->disconnects->stream(411));
        }
        return new SessionEvent(SessionEvent::IGNORED, ['tag' => 'ib']);
    }

    private function disconnect(DisconnectDecision $decision): SessionEvent
    {
        $this->connection->close();
        $this->state = $decision->shouldReconnect() ? self::CLOSED : self::FAILED;
        return new SessionEvent(SessionEvent::DISCONNECTED, [
            'code' => $decision->code(), 'reason' => $decision->reason(),
        ], $decision->shouldReconnect(), $decision->credentialResetRequired());
    }

    private function streamDecision(BinaryNode $node): DisconnectDecision
    {
        $code = $this->statusCode($node->attribute('code'), 500);
        $conflict = $node->child('conflict');
        return $this->disconnects->stream($code, $conflict instanceof BinaryNode ? $conflict->attribute('type') : null);
    }

    private function failureDecision(BinaryNode $node): DisconnectDecision
    {
        return $this->disconnects->failure($this->statusCode($node->attribute('reason'), 500));
    }

    private function serverIqId(BinaryNode $node, string $type): string
    {
        $id = $node->attribute('id');
        if ($node->tag() !== 'iq') { throw new ProtocolException('Server pairing envelope is not an IQ stanza.'); }
        if ($node->attribute('from') === null) { throw new ProtocolException('Server pairing IQ source is missing.'); }
        if ($node->attribute('from') !== ServerJid::WHATSAPP) { throw new ProtocolException('Server pairing IQ source is unexpected.'); }
        if ($node->attribute('type') !== $type) { throw new ProtocolException('Server pairing IQ type is invalid.'); }
        if (!is_string($id) || $id === '' || strlen($id) > 256
            || preg_match('//u', $id) !== 1 || preg_match('/[,\x00-\x1f\x7f]/', $id) === 1) {
            throw new ProtocolException('Server pairing IQ ID is invalid.');
        }
        return $id;
    }

     
    private function offlineCounts(BinaryNode $node): array
    {
        $result = [];
        foreach (['count', 'appdata', 'message', 'notification', 'receipt'] as $name) {
            $result[$name] = $this->count($node->attribute($name));
        }
        return $result;
    }

    private function count(?string $value): int
    {
        if ($value === null) { return 0; }
        if (preg_match('/^(0|[1-9][0-9]{0,8})$/D', $value) !== 1) {
            throw new ProtocolException('Offline synchronization counter is invalid.');
        }
        return (int) $value;
    }

    private function statusCode(?string $value, int $default): int
    {
        if ($value === null || $value === '') { return $default; }
        if (preg_match('/^[1-9][0-9]{2}$/D', $value) !== 1) {
            throw new ProtocolException('Connection status code is invalid.');
        }
        return (int) $value;
    }
}
