<?php

declare(strict_types=1);

namespace PhpWA\Client;

use PhpWA\Auth\AccountIdentity;
use PhpWA\Auth\AuthenticationCredentials;
use PhpWA\Exception\ProtocolException;
use PhpWA\Protocol\Binary\BinaryNode;
use PhpWA\Protocol\Jid\Jid;

final class IncomingMessageEnvelope
{
    /** @var BinaryNode */ private $stanza;
    /** @var string */ private $id;
    /** @var string */ private $chatJid;
    /** @var string */ private $senderJid;
    /** @var string|null */ private $participantJid;
    /** @var string|null */ private $alternateSenderJid;
    /** @var bool */ private $fromMe;
    /** @var bool */ private $group;
    /** @var int */ private $timestamp;
    /** @var string|null */ private $pushName;
    /** @var string|null */ private $category;
    /** @var bool */ private $offline;
    /** @var list<EncryptedMessagePart> */ private $parts;

    /** @param list<EncryptedMessagePart> $parts */
    private function __construct(BinaryNode $stanza, string $id, string $chatJid, string $senderJid, ?string $participantJid, ?string $alternateSenderJid, bool $fromMe, bool $group, int $timestamp, ?string $pushName, ?string $category, bool $offline, array $parts)
    {
        $this->stanza = $stanza; $this->id = $id; $this->chatJid = $chatJid; $this->senderJid = $senderJid;
        $this->participantJid = $participantJid; $this->alternateSenderJid = $alternateSenderJid;
        $this->fromMe = $fromMe; $this->group = $group; $this->timestamp = $timestamp; $this->pushName = $pushName;
        $this->category = $category; $this->offline = $offline; $this->parts = $parts;
    }

    public static function parse(BinaryNode $stanza, AuthenticationCredentials $credentials): self
    {
        if ($stanza->tag() !== 'message') { throw new ProtocolException('Incoming encrypted stanza must be a message.'); }
        $identity = $credentials->me();
        if (!$identity instanceof AccountIdentity) { throw new ProtocolException('Incoming message cannot be parsed before pairing.'); }
        $id = self::identifier($stanza->attribute('id'), 'message ID', 512);
        $from = self::jid($stanza->attribute('from'), 'message sender');
        $participant = $stanza->attribute('participant') === null ? null : self::jid($stanza->attribute('participant'), 'message participant');
        $recipient = $stanza->attribute('recipient') === null ? null : self::jid($stanza->attribute('recipient'), 'message recipient');
        $ownPhone = self::jid($identity->id(), 'own account');
        $ownLid = $identity->lid() === null ? null : self::jid($identity->lid(), 'own LID');
        $group = $from->isGroup() || $from->isBroadcast();
        if ($group) {
            if (!$participant instanceof Jid) { throw new ProtocolException('Group message has no participant.'); }
            $sender = $participant;
            $chat = $from;
        } else {
            if (!self::isDirect($from)) { throw new ProtocolException('Unsupported incoming message address domain.'); }
            $sender = $from;
            $chat = $recipient ?? $from;
        }
        $fromMe = self::sameAccount($sender, $ownPhone, $ownLid);
        if (!$group && $recipient instanceof Jid && !$fromMe) {
            throw new ProtocolException('A recipient is present on a message that is not from this account.');
        }
        self::assertMessageJid($chat, 'chat');
        self::assertMessageJid($sender, 'sender');

        $addressingMode = $stanza->attribute('addressing_mode');
        if ($addressingMode !== null && $addressingMode !== 'pn' && $addressingMode !== 'lid') {
            throw new ProtocolException('Incoming message addressing mode is invalid.');
        }
        $alternateText = ($addressingMode === 'lid' || ($addressingMode === null && ($sender->isLid() || $sender->isHostedLid())))
            ? ($stanza->attribute('participant_pn') ?? $stanza->attribute('sender_pn'))
            : ($stanza->attribute('participant_lid') ?? $stanza->attribute('sender_lid'));
        $alternate = $alternateText === null ? null : self::jid($alternateText, 'alternate sender');
        if ($alternate instanceof Jid && ($alternate->device() === null || $alternate->device() === 0) && $sender->device() !== null) {
            $alternate = $alternate->withDeviceFrom($sender);
        }
        if ($alternate instanceof Jid && !self::oppositeAddressTypes($sender, $alternate)) {
            throw new ProtocolException('Alternate sender does not form a LID/phone mapping.');
        }

        $timestamp = self::unsigned($stanza->attribute('t'), 'message timestamp', PHP_INT_MAX, false);
        $pushName = self::optionalText($stanza->attribute('notify'), 512);
        $category = self::optionalIdentifier($stanza->attribute('category'), 64);
        $offline = $stanza->attribute('offline') !== null;
        $parts = [];
        $total = 0;
        foreach ($stanza->childrenNamed('enc') as $child) {
            if (count($parts) >= 8) { throw new ProtocolException('Incoming message has too many encrypted parts.'); }
            $type = self::identifier($child->attribute('type'), 'encryption type', 16);
            $version = self::unsigned($child->attribute('v'), 'encryption version', 3, false);
            $count = self::unsigned($child->attribute('count'), 'retry count', 10, true);
            $bytes = $child->bytesContent();
            if (!is_string($bytes)) { throw new ProtocolException('Encrypted message content must be bytes.'); }
            try { $part = new EncryptedMessagePart($type, $version, $bytes, $count); }
            catch (\Throwable $error) { throw new ProtocolException('Encrypted message part is invalid.', 0, $error); }
            if (!$group && $type === 'skmsg') { throw new ProtocolException('Sender Key message was received outside a group.'); }
            $total += strlen($bytes);
            if ($total > 8388608) { throw new ProtocolException('Combined encrypted message payload exceeds its limit.'); }
            $parts[] = $part;
        }
        if ($parts === []) { throw new ProtocolException('Incoming message has no supported encrypted content.'); }
        return new self(
            $stanza, $id, self::withoutDevice($chat), (string) $sender,
            $group ? (string) $sender : null, $alternate instanceof Jid ? (string) $alternate : null,
            $fromMe, $group, $timestamp, $pushName, $category, $offline, $parts
        );
    }

    public function stanza(): BinaryNode { return $this->stanza; }
    public function id(): string { return $this->id; }
    public function chatJid(): string { return $this->chatJid; }
    public function senderJid(): string { return $this->senderJid; }
    public function participantJid(): ?string { return $this->participantJid; }
    public function alternateSenderJid(): ?string { return $this->alternateSenderJid; }
    public function isFromMe(): bool { return $this->fromMe; }
    public function isGroup(): bool { return $this->group; }
    public function timestamp(): int { return $this->timestamp; }
    public function pushName(): ?string { return $this->pushName; }
    public function category(): ?string { return $this->category; }
    public function isOffline(): bool { return $this->offline; }
    /** @return list<EncryptedMessagePart> */ public function parts(): array { return $this->parts; }

    private static function jid(?string $value, string $field): Jid
    {
        $jid = Jid::parse($value);
        if (!$jid instanceof Jid) { throw new ProtocolException('Invalid ' . $field . ' JID.'); }
        return $jid;
    }

    private static function isDirect(Jid $jid): bool
    {
        return $jid->isPhoneNumber() || $jid->isHostedPhoneNumber() || $jid->isLid() || $jid->isHostedLid() || $jid->isMetaAi();
    }

    private static function assertMessageJid(Jid $jid, string $field): void
    {
        $user = $jid->user();
        if ($user === '' || strlen($user) > 128 || preg_match('/^[A-Za-z0-9_.-]+$/D', $user) !== 1) {
            throw new ProtocolException('Incoming ' . $field . ' JID has an invalid user part.');
        }
        if (($jid->isPhoneNumber() || $jid->isHostedPhoneNumber() || $jid->isLid() || $jid->isHostedLid())
            && preg_match('/^[1-9][0-9]{0,19}$/D', $user) !== 1) {
            throw new ProtocolException('Incoming ' . $field . ' account identifier is not numeric.');
        }
    }

    private static function oppositeAddressTypes(Jid $left, Jid $right): bool
    {
        $leftPhone = $left->isPhoneNumber() || $left->isHostedPhoneNumber();
        $rightPhone = $right->isPhoneNumber() || $right->isHostedPhoneNumber();
        $leftLid = $left->isLid() || $left->isHostedLid();
        $rightLid = $right->isLid() || $right->isHostedLid();
        return ($leftPhone && $rightLid) || ($leftLid && $rightPhone);
    }

    private static function sameAccount(Jid $candidate, Jid $phone, ?Jid $lid): bool
    {
        return (($candidate->isPhoneNumber() || $candidate->isHostedPhoneNumber()) && $candidate->sameUser($phone))
            || ($lid instanceof Jid && ($candidate->isLid() || $candidate->isHostedLid()) && $candidate->sameUser($lid));
    }

    private static function withoutDevice(Jid $jid): string { return Jid::encode($jid->user(), $jid->server()); }

    private static function identifier(?string $value, string $field, int $max): string
    {
        if (!is_string($value) || $value === '' || strlen($value) > $max || preg_match('//u', $value) !== 1
            || preg_match('/[,\x00-\x20\x7f]/', $value) === 1) {
            throw new ProtocolException('Invalid ' . $field . '.');
        }
        return $value;
    }

    private static function optionalIdentifier(?string $value, int $max): ?string
    {
        if ($value === null) { return null; }
        return self::identifier($value, 'message category', $max);
    }

    private static function optionalText(?string $value, int $max): ?string
    {
        if ($value === null) { return null; }
        if ($value === '' || strlen($value) > $max || preg_match('//u', $value) !== 1
            || preg_match('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', $value) === 1) {
            throw new ProtocolException('Message display text is invalid.');
        }
        return $value;
    }

    private static function unsigned(?string $value, string $field, int $max, bool $optional): int
    {
        if ($value === null && $optional) { return 0; }
        if (!is_string($value) || preg_match('/^(0|[1-9][0-9]{0,18})$/D', $value) !== 1
            || strlen($value) > strlen((string) $max)
            || (strlen($value) === strlen((string) $max) && strcmp($value, (string) $max) > 0)) {
            throw new ProtocolException('Invalid ' . $field . '.');
        }
        return (int) $value;
    }
}
