<?php

declare(strict_types=1);

namespace PhpWA\Client;

use InvalidArgumentException;
use PhpWA\Exception\ProtocolException;
use PhpWA\Protocol\Binary\BinaryNode;
use PhpWA\Protocol\Jid\Jid;
use PhpWA\Protocol\Jid\JidDomain;
use PhpWA\Protocol\Jid\ServerJid;
use PhpWA\Runtime\Deadline;

final class UsyncDeviceDirectory implements DeviceDirectoryInterface
{
    /** @var QueryExecutorInterface */ private $queries;
    /** @var MessageIdGeneratorInterface */ private $ids;
    /** @var int */ private $chunkSize;

    public function __construct(QueryExecutorInterface $queries, ?MessageIdGeneratorInterface $ids = null, int $chunkSize = 100)
    {
        if ($chunkSize < 1 || $chunkSize > 500) { throw new InvalidArgumentException('USync chunk size is invalid.'); }
        $this->queries = $queries;
        $this->ids = $ids ?? new RandomMessageIdGenerator();
        $this->chunkSize = $chunkSize;
    }

    public function devices(array $userJids, Deadline $deadline): DeviceDiscoveryResult
    {
        if ($userJids === [] || count($userJids) > 10000) { throw new InvalidArgumentException('USync user list is empty or too large.'); }
        $users = [];
        foreach ($userJids as $value) {
            $jid = is_string($value) ? Jid::parse($value) : null;
            if (!$jid instanceof Jid || (!$jid->isPhoneNumber() && !$jid->isLid()
                && !$jid->isHostedPhoneNumber() && !$jid->isHostedLid())) {
                throw new InvalidArgumentException('USync accepts only phone or LID user JIDs.');
            }
            $base = new Jid($jid->user(), $jid->server(), null, $jid->domainType());
            $users[(string) $base] = $base;
        }
        $devices = [];
        $mappings = [];
        foreach (array_chunk(array_values($users), $this->chunkSize) as $chunk) {
            $this->fetchChunk($chunk, $deadline, $devices, $mappings);
        }
        return new DeviceDiscoveryResult(array_keys($devices), $mappings);
    }

    /** @param list<Jid> $users @param array<string,bool> $devices @param array<string,string> $mappings */
    private function fetchChunk(array $users, Deadline $deadline, array &$devices, array &$mappings): void
    {
        $requested = [];
        $userNodes = [];
        foreach ($users as $jid) {
            $requested[(string) $jid] = $jid;
            $userNodes[] = new BinaryNode('user', ['jid' => (string) $jid]);
        }
        $request = BinaryNode::children('iq', [
            'to' => ServerJid::WHATSAPP, 'type' => 'get', 'xmlns' => 'usync', 'id' => $this->ids->generate(),
        ], [BinaryNode::children('usync', [
            'sid' => $this->ids->generate(), 'mode' => 'query', 'last' => 'true', 'index' => '0', 'context' => 'message',
        ], [
            BinaryNode::children('query', [], [new BinaryNode('devices', ['version' => '2']), new BinaryNode('lid')]),
            BinaryNode::children('list', [], $userNodes),
        ])]);
        $response = $this->queries->query($request, $deadline);
        if ($response->tag() !== 'iq' || $response->attribute('type') !== 'result') {
            throw new ProtocolException('USync returned an invalid IQ result.');
        }
        $usync = $response->child('usync');
        $list = $usync instanceof BinaryNode ? $usync->child('list') : null;
        if (!$list instanceof BinaryNode) { throw new ProtocolException('USync result has no user list.'); }
        $seen = [];
        foreach ($list->childrenNamed('user') as $userNode) {
            $sourceText = $userNode->attribute('jid');
            $source = $sourceText === null ? null : Jid::parse($sourceText);
            if (!$source instanceof Jid) { throw new ProtocolException('USync result contains an invalid user JID.'); }
            $source = new Jid($source->user(), $source->server(), null, $source->domainType());
            if (!isset($requested[(string) $source]) || isset($seen[(string) $source])) {
                throw new ProtocolException('USync result contains an unexpected or duplicate user.');
            }
            $seen[(string) $source] = true;
            $lidNode = $userNode->child('lid');
            if ($lidNode instanceof BinaryNode && $lidNode->attribute('val') !== null) {
                $lid = Jid::parse($lidNode->attribute('val'));
                if (!$lid instanceof Jid || (!$lid->isLid() && !$lid->isHostedLid())) {
                    throw new ProtocolException('USync returned an invalid LID mapping.');
                }
                $lid = new Jid($lid->user(), $lid->server(), null, $lid->domainType());
                if ($source->isPhoneNumber() || $source->isHostedPhoneNumber()) {
                    if (isset($mappings[(string) $source]) && !hash_equals($mappings[(string) $source], (string) $lid)) {
                        throw new ProtocolException('USync returned conflicting LID mappings.');
                    }
                    $mappings[(string) $source] = (string) $lid;
                }
            }
            $deviceContainer = $userNode->child('devices');
            $deviceList = $deviceContainer instanceof BinaryNode ? $deviceContainer->child('device-list') : null;
            if (!$deviceList instanceof BinaryNode) { throw new ProtocolException('USync user has no device list.'); }
            foreach ($deviceList->childrenNamed('device') as $deviceNode) {
                $id = $this->boundedDecimal($deviceNode->attribute('id'), 'USync device ID', 255);
                $isHosted = $deviceNode->attribute('is_hosted');
                if ($isHosted !== null && $isHosted !== 'true' && $isHosted !== 'false') {
                    throw new ProtocolException('USync hosted-device flag is invalid.');
                }
                $server = $source->server();
                $domainType = $source->domainType();
                if ($isHosted === 'true') {
                    $server = ($source->isLid() || $source->isHostedLid()) ? 'hosted.lid' : 'hosted';
                    $domainType = $server === 'hosted.lid' ? JidDomain::HOSTED_LID : JidDomain::HOSTED;
                }
                $devices[(string) new Jid($source->user(), $server, $id, $domainType)] = true;
                if (count($devices) > 10000) { throw new ProtocolException('USync device result exceeds its limit.'); }
            }
        }
        if (count($seen) !== count($requested)) { throw new ProtocolException('USync omitted one or more requested users.'); }
    }

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