<?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\Crypto\SystemRandomSource;
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;

/** Auth/reconnect/offline state machine for one already locked account worker. */
final class ConnectionSession
{
    public const AWAITING_AUTH = 'awaiting_auth';
    public const PAIRING = 'pairing';
    public const AWAITING_RESTART = 'awaiting_restart';
    public const ACTIVATING = 'activating';
    public const ONLINE = 'online';
    public const CLOSED = 'closed';
    public const FAILED = 'failed';

    /** @var string */ private $accountId;
    /** @var ProtocolConnectionInterface */ private $connection;
    /** @var AccountStoreInterface */ private $accounts;
    /** @var TransactionalCredentialStore */ private $credentialStore;
    /** @var CredentialRecord */ private $record;
    /** @var PairingProcessorInterface */ private $pairingProcessor;
    /** @var PairingCommitter */ private $pairingCommitter;
    /** @var QrPayloadBuilder */ private $qr;
    /** @var CompanionDeviceConfig */ private $config;
    /** @var DisconnectClassifier */ private $disconnects;
    /** @var string */ private $state = self::AWAITING_AUTH;
    /** @var string|null */ private $activeIqId;
    /** @var string|null */ private $activeQrReference;

    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() === 'notification' && $node->attribute('type') === 'companion_reg_refresh') {
                return $this->companionRegistrationRefresh($node, $deadline);
            }
            if ($this->state === self::ACTIVATING && $node->tag() === 'iq'
                && $this->activeIqId !== null && $node->attribute('id') === $this->activeIqId) {
                return $this->activeModeConfirmed($node);
            }
            if ($node->tag() === 'success') { return $this->success($node, $deadline); }
            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; }

    /** Updates credentials changed by the authenticated phone-code sub-flow. */
    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);
            }
        }
        // WhatsApp may retire the ADV secret after the phone scans this QR.
        // Keep the displayed reference so the subsequent refresh can advertise
        // a new secret without consuming another server-issued reference.
        $this->activeQrReference = $refs[0]->bytesContent();
        $this->state = self::PAIRING;
        return new SessionEvent(SessionEvent::QR, ['codes' => $codes]);
    }

    /**
     * WhatsApp Web refreshes the registration material after an unpaired phone
     * scans a QR. Reusing the old ADV secret makes the phone reject the link.
     * Persist the new secret before acknowledging the notification, then emit a
     * QR built from the same live reference so a worker restart cannot restore
     * a secret the server has already retired.
     */
    private function companionRegistrationRefresh(BinaryNode $node, Deadline $deadline): SessionEvent
    {
        if ($this->state !== self::PAIRING || $this->record->credentials()->me() !== null
            || !is_string($this->activeQrReference) || $this->activeQrReference === '') {
            return new SessionEvent(SessionEvent::IGNORED, ['tag' => 'notification']);
        }
        $id = $this->notificationIdentifier($node->attribute('id'), 'notification ID');
        $from = $this->notificationIdentifier($node->attribute('from'), 'notification source');
        if ($from !== ServerJid::WHATSAPP || !$this->isCompanionRefresh($node)) {
            return new SessionEvent(SessionEvent::IGNORED, ['tag' => 'notification']);
        }
        $updated = $this->record->credentials()->withAdvSecretKey((new SystemRandomSource())->bytes(32));
        $this->record = $this->credentialStore->save($this->accountId, $updated, $this->record->revision());
        $this->connection->send(new BinaryNode('ack', [
            'class' => 'notification', 'id' => $id, 'to' => $from, 'type' => 'companion_reg_refresh',
        ]), $deadline);
        return new SessionEvent(SessionEvent::QR, [
            'codes' => [$this->qr->build($this->activeQrReference, $this->record->credentials(), $this->config)],
        ]);
    }

    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());
        // Persist credentials, own Signal identity and LID mappings before the
        // server acknowledgement can make the pairing externally visible.
        $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, Deadline $deadline): 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.');
            }
        }
        // Login starts in passive mode. WhatsApp does not reliably deliver
        // queued or new events until the linked device explicitly becomes active.
        $this->activeIqId = 'PHPWA-' . strtoupper(bin2hex(random_bytes(8)));
        $this->connection->send(BinaryNode::children('iq', [
            'to' => ServerJid::WHATSAPP,
            'type' => 'set',
            'xmlns' => 'passive',
            'id' => $this->activeIqId,
        ], [new BinaryNode('active')]), $deadline);
        $this->state = self::ACTIVATING;
        return new SessionEvent(SessionEvent::IGNORED, ['tag' => 'success']);
    }

    private function activeModeConfirmed(BinaryNode $node): SessionEvent
    {
        if ($node->attribute('type') !== 'result') {
            throw new ProtocolException('WhatsApp rejected the active linked-device mode.');
        }
        $updated = $this->record->credentials()->withRegistered(true);
        $this->record = $this->credentialStore->save($this->accountId, $updated, $this->record->revision());
        $this->activeIqId = null;
        $this->state = self::ONLINE;
        $identity = $this->record->credentials()->me();
        return new SessionEvent(SessionEvent::ONLINE, [
            'jid' => $identity instanceof AccountIdentity ? $identity->id() : null,
            'lid' => $identity instanceof AccountIdentity ? $identity->lid() : null,
        ]);
    }

    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 isCompanionRefresh(BinaryNode $node): bool
    {
        $children = $node->childNodes();
        if (count($children) !== 1 || !$children[0] instanceof BinaryNode) { return false; }
        return in_array($children[0]->tag(), ['companion_reg_refresh', 'pair-device-rotate-qr'], true);
    }

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

    /** @return array<string,int> */
    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;
    }
}
