<?php

declare(strict_types=1);

namespace PhpWA\Client;

use PhpWA\Auth\AuthenticationCredentials;
use PhpWA\Crypto\Signal\GroupCipher;
use PhpWA\Crypto\Signal\GroupSessionBuilder;
use PhpWA\Crypto\Signal\SenderKeyName;
use PhpWA\Crypto\Signal\SessionCipher;
use PhpWA\Crypto\Signal\TransactionalSignalStore;
use PhpWA\Exception\ProtocolException;
use PhpWA\Exception\SignalException;
use PhpWA\Protocol\Jid\Jid;
use PhpWA\Protocol\Proto\Message;
use PhpWA\Protocol\ProtobufCodec;
use PhpWA\Storage\AccountTransactionInterface;

final class SignalIncomingMessageDecryptor implements IncomingMessageDecryptorInterface
{
    private const MAX_PLAINTEXT_BYTES = 3000000;
      private $protobuf;

    public function __construct(?ProtobufCodec $protobuf = null)
    {
        $this->protobuf = $protobuf ?? new ProtobufCodec(self::MAX_PLAINTEXT_BYTES);
    }

    public function decrypt(AccountTransactionInterface $transaction, AuthenticationCredentials $credentials, IncomingMessageEnvelope $envelope): DecryptedIncomingMessage
    {
        $store = new TransactionalSignalStore($transaction, $credentials);
        $signalSender = $this->resolveSignalSender($transaction, $envelope);
        $combined = new Message();
        $decryptedCount = 0;
        foreach ($envelope->parts() as $part) {
            if ($part->type() === 'pkmsg') {
                $plaintext = (new SessionCipher($store, $signalSender))->decryptPreKeyMessage($part->ciphertext());
            } elseif ($part->type() === 'msg') {
                $plaintext = (new SessionCipher($store, $signalSender))->decryptMessage($part->ciphertext());
            } elseif ($part->type() === 'skmsg' && $envelope->isGroup()) {
                $plaintext = (new GroupCipher($store, SenderKeyName::compose($envelope->chatJid(), $signalSender)))
                    ->decrypt($part->ciphertext());
            } else {
                throw new ProtocolException('Encrypted message type is not valid for this chat.');
            }
            $plaintext = $this->unpad($plaintext, $part->version());
             
            $message = $this->protobuf->decode(Message::class, $plaintext);
            if ($message->hasDeviceSentMessage() && $message->getDeviceSentMessage()->hasMessage()) {
                $message = $message->getDeviceSentMessage()->getMessage();
            }
            $distribution = $message->getSenderKeyDistributionMessage();
            if ($distribution !== null) {
                $groupId = $distribution->getGroupId();
                $group = Jid::parse($groupId);
                $bytes = $distribution->getAxolotlSenderKeyDistributionMessage();
                if (!$group instanceof Jid || !$group->isGroup() || $bytes === '') {
                    throw new ProtocolException('Sender Key distribution message is malformed.');
                }
                (new GroupSessionBuilder($store))->process(SenderKeyName::compose((string) $group, $signalSender), $bytes);
            }
            $combined->mergeFrom($message);
            $decryptedCount++;
        }
        if ($decryptedCount === 0 || $combined->serializeToString() === '') {
            throw new SignalException('No encrypted message part produced a protobuf message.');
        }
        return new DecryptedIncomingMessage($combined);
    }

    private function resolveSignalSender(AccountTransactionInterface $transaction, IncomingMessageEnvelope $envelope): string
    {
        $sender = Jid::parse($envelope->senderJid());
        if (!$sender instanceof Jid) { throw new ProtocolException('Message sender JID became invalid.'); }
        $alternate = $envelope->alternateSenderJid() === null ? null : Jid::parse($envelope->alternateSenderJid());
        if ($alternate instanceof Jid) {
            $phone = ($sender->isPhoneNumber() || $sender->isHostedPhoneNumber()) ? $sender : $alternate;
            $lid = ($sender->isLid() || $sender->isHostedLid()) ? $sender : $alternate;
            $this->pin($transaction, 'lid_mapping', (string) $lid, (string) $phone);
            $this->pin($transaction, 'pn_mapping', (string) $phone, (string) $lid);
            return (string) $lid;
        }
        if ($sender->isLid() || $sender->isHostedLid()) { return (string) $sender; }
        if ($sender->isPhoneNumber() || $sender->isHostedPhoneNumber()) {
            $mapped = $transaction->get('pn_mapping', (string) $sender);
            if ($mapped !== null) {
                $lid = Jid::parse($mapped->value());
                if (!$lid instanceof Jid || (!$lid->isLid() && !$lid->isHostedLid())) {
                    throw new ProtocolException('Stored phone-to-LID mapping is malformed.');
                }
                if (($lid->device() === null || $lid->device() === 0) && $sender->device() !== null) {
                    $lid = $lid->withDeviceFrom($sender);
                }
                return (string) $lid;
            }
        }
        return (string) $sender;
    }

    private function pin(AccountTransactionInterface $transaction, string $collection, string $key, string $value): void
    {
        $known = $transaction->get($collection, $key);
        if ($known === null) { $transaction->put($collection, $key, $value, 0); return; }
        if (!hash_equals($known->value(), $value)) {
            throw new SignalException('Incoming LID/phone mapping conflicts with pinned state.');
        }
    }

    private function unpad(string $plaintext, int $version): string
    {
        if ($version === 3) {
            if ($plaintext === '' || strlen($plaintext) > self::MAX_PLAINTEXT_BYTES) {
                throw new SignalException('Signal v3 plaintext is empty or too large.');
            }
            return $plaintext;
        }
        $length = strlen($plaintext);
        if ($length === 0 || $length > self::MAX_PLAINTEXT_BYTES + 16) {
            throw new SignalException('Signal padded plaintext is empty or too large.');
        }
        $padding = ord($plaintext[$length - 1]);
        if ($padding < 1 || $padding > 16 || $padding > $length
            || !hash_equals(str_repeat(chr($padding), $padding), substr($plaintext, -$padding))) {
            throw new SignalException('Signal plaintext padding is invalid.');
        }
        $unpadded = substr($plaintext, 0, $length - $padding);
        if ($unpadded === '') { throw new SignalException('Signal plaintext contains only padding.'); }
        return $unpadded;
    }
}
