<?php

declare(strict_types=1);

namespace PhpWA\Client;

use InvalidArgumentException;
use PhpWA\Auth\AccountIdentity;
use PhpWA\Auth\AuthenticationCredentials;
use PhpWA\Crypto\RandomSourceInterface;
use PhpWA\Crypto\Signal\GroupCipher;
use PhpWA\Crypto\Signal\GroupSessionBuilder;
use PhpWA\Crypto\Signal\PreKeyBundle;
use PhpWA\Crypto\Signal\SenderKeyName;
use PhpWA\Crypto\Signal\SessionBuilder;
use PhpWA\Crypto\Signal\SessionCipher;
use PhpWA\Crypto\Signal\SignalCiphertext;
use PhpWA\Crypto\Signal\TransactionalSignalStore;
use PhpWA\Crypto\SystemRandomSource;
use PhpWA\Exception\OutgoingMessagePendingException;
use PhpWA\Exception\ProtocolException;
use PhpWA\Exception\SignalException;
use PhpWA\Protocol\Binary\BinaryNode;
use PhpWA\Protocol\Jid\Jid;
use PhpWA\Protocol\Proto\Message;
use PhpWA\Protocol\Proto\Message\SenderKeyDistributionMessage;
use PhpWA\Protocol\ProtobufCodec;
use PhpWA\Runtime\Deadline;
use PhpWA\Storage\AccountStoreInterface;
use PhpWA\Storage\AccountTransactionInterface;
use PhpWA\Storage\CredentialRecord;
use PhpWA\Storage\TransactionalCredentialStore;

final class GroupMessageSender
{
    private const MEMORY = 'sender_key_memory';
    /** @var string */ private $accountId;
    /** @var ProtocolConnectionInterface */ private $connection;
    /** @var AccountStoreInterface */ private $accounts;
    /** @var CredentialRecord */ private $record;
    /** @var GroupDirectoryInterface */ private $groups;
    /** @var DeviceDirectoryInterface */ private $devices;
    /** @var PreKeyDirectoryInterface */ private $preKeys;
    /** @var OutgoingStanzaStore */ private $outgoing;
    /** @var MessageIdGeneratorInterface */ private $ids;
    /** @var RandomSourceInterface */ private $random;
    /** @var ProtobufCodec */ private $protobuf;

    public function __construct(
        string $accountId,
        ProtocolConnectionInterface $connection,
        AccountStoreInterface $accounts,
        CredentialRecord $record,
        GroupDirectoryInterface $groups,
        DeviceDirectoryInterface $devices,
        PreKeyDirectoryInterface $preKeys,
        ?OutgoingStanzaStore $outgoing = null,
        ?MessageIdGeneratorInterface $ids = null,
        ?RandomSourceInterface $random = null
    ) {
        if ($accountId === '' || strlen($accountId) > 512 || preg_match('//u', $accountId) !== 1
            || preg_match('/[\x00-\x1f\x7f]/', $accountId) === 1) {
            throw new InvalidArgumentException('Group sender account ID is invalid.');
        }
        $this->accountId = $accountId; $this->connection = $connection; $this->accounts = $accounts; $this->record = $record;
        $this->groups = $groups; $this->devices = $devices; $this->preKeys = $preKeys;
        $this->outgoing = $outgoing ?? new OutgoingStanzaStore($accounts); $this->ids = $ids ?? new RandomMessageIdGenerator();
        $this->random = $random ?? new SystemRandomSource(); $this->protobuf = new ProtobufCodec(4194304);
    }

    public function sendText(string $groupJid, string $text, Deadline $deadline): OutgoingMessageResult
    {
        if ($text === '' || strlen($text) > 1048576 || preg_match('//u', $text) !== 1) {
            throw new InvalidArgumentException('Outgoing group text is empty, malformed or too large.');
        }
        return $this->send($groupJid, new Message(['conversation' => $text]), $deadline);
    }

    public function send(string $groupJid, Message $message, Deadline $deadline): OutgoingMessageResult
    {
        if ($this->outgoing->hasPending($this->accountId)) {
            throw new ProtocolException('The durable outgoing queue must be replayed and ACKed before encrypting another message.');
        }
        $credentials = $this->record->credentials(); $identity = $credentials->me();
        if (!$credentials->isRegistered() || !$identity instanceof AccountIdentity || $credentials->signedDeviceIdentity() === null) {
            throw new ProtocolException('A paired and registered account is required to send group messages.');
        }
        $metadata = $this->groups->metadata($groupJid, $deadline);
        $discovery = $this->devices->devices($metadata->participants(), $deadline);
        $mappings = $this->mergeMappings($metadata->phoneToLid(), $discovery->phoneToLid());
        [$wireToSignal, $recipients] = $this->resolveRecipients($discovery->devices(), $mappings, $identity);
        $sender = $this->senderAddress($metadata->addressingMode(), $identity);
        $senderKeyName = SenderKeyName::compose($metadata->groupJid(), $sender);

        $preflight = $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $tx) use ($credentials, $metadata, $wireToSignal): array {
                $this->assertCredentialRevision($tx); $signal = new TransactionalSignalStore($tx, $credentials);
                $distribution = []; $missing = [];
                foreach ($wireToSignal as $wire => $signalJid) {
                    if ($tx->get(self::MEMORY, $this->memoryKey($metadata->groupJid(), $wire)) !== null) { continue; }
                    $distribution[] = $wire;
                    if ($signal->loadSession($signalJid) === null) { $missing[$signalJid] = true; }
                }
                return ['distribution' => $distribution, 'missing' => array_keys($missing)];
            }
        );
        $bundles = $preflight['missing'] === [] ? [] : $this->preKeys->fetch($preflight['missing'], $deadline);
        $id = $this->ids->generate(); $phash = self::participantHash($recipients);
        $stanza = $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $tx) use ($credentials, $metadata, $mappings, $wireToSignal, $recipients, $preflight, $bundles, $senderKeyName, $message, $id, $phash): BinaryNode {
                $this->assertCredentialRevision($tx); $this->pinMappings($tx, $mappings);
                $signal = new TransactionalSignalStore($tx, $credentials);
                $distributionBytes = (new GroupSessionBuilder($signal, $this->random))->create($senderKeyName);
                $distribution = new Message(['senderKeyDistributionMessage' => new SenderKeyDistributionMessage([
                    'groupId' => $metadata->groupJid(), 'axolotlSenderKeyDistributionMessage' => $distributionBytes,
                ])]);
                $distributionPlain = $this->protobuf->encode($distribution);
                $participants = []; $includeIdentity = false;
                foreach ($preflight['distribution'] as $wire) {
                    $signalJid = $wireToSignal[$wire];
                    if ($signal->loadSession($signalJid) === null) {
                        $bundle = $bundles[$signalJid] ?? null;
                        if (!$bundle instanceof PreKeyBundle) { throw new SignalException('A required group recipient pre-key bundle is unavailable.'); }
                        (new SessionBuilder($signal, $signalJid))->initializeOutgoing($bundle);
                    }
                    $ciphertext = (new SessionCipher($signal, $signalJid))->encrypt($this->pad($distributionPlain));
                    $type = $ciphertext->type() === SignalCiphertext::PREKEY ? 'pkmsg' : 'msg';
                    if ($type === 'pkmsg') { $includeIdentity = true; }
                    $participants[] = BinaryNode::children('to', ['jid' => $wire], [
                        BinaryNode::bytes('enc', ['v' => '2', 'type' => $type], $ciphertext->body()),
                    ]);
                    $tx->put(self::MEMORY, $this->memoryKey($metadata->groupJid(), $wire), '1');
                }
                $plain = $this->protobuf->encode($message);
                if ($plain === '') { throw new ProtocolException('Outgoing group protobuf message is empty.'); }
                $groupCiphertext = (new GroupCipher($signal, $senderKeyName))->encrypt($this->pad($plain));
                $children = [
                    BinaryNode::children('participants', [], $participants),
                    BinaryNode::bytes('enc', ['v' => '2', 'type' => 'skmsg'], $groupCiphertext),
                ];
                if ($includeIdentity) { $children[] = BinaryNode::bytes('device-identity', [], (string) $credentials->signedDeviceIdentity()); }
                $node = BinaryNode::children('message', [
                    'id' => $id, 'to' => $metadata->groupJid(), 'type' => $this->messageType($message),
                    'addressing_mode' => $metadata->addressingMode(), 'phash' => $phash,
                ], $children);
                $this->outgoing->stageInTransaction($tx, $id, $node);
                $this->outgoing->stageMessageInTransaction($tx, $id, $metadata->groupJid(), true, $message, time());
                return $node;
            }
        );
        try { $this->connection->send($stanza, $deadline); }
        catch (\Throwable $error) { throw new OutgoingMessagePendingException($id, $error); }
        return new OutgoingMessageResult($id, $phash, count($recipients));
    }

    /** @param array<string,string> $left @param array<string,string> $right @return array<string,string> */
    private function mergeMappings(array $left, array $right): array
    {
        foreach ($right as $phone => $lid) {
            if (isset($left[$phone]) && !hash_equals($left[$phone], $lid)) { throw new SignalException('Group and USync returned conflicting LID mappings.'); }
            $left[$phone] = $lid;
        }
        return $left;
    }

    /** @param list<string> $devices @param array<string,string> $mappings @return array{0:array<string,string>,1:list<string>} */
    private function resolveRecipients(array $devices, array $mappings, AccountIdentity $identity): array
    {
        $wireToSignal = []; $recipients = [];
        foreach ($devices as $wireText) {
            $wire = Jid::parse($wireText);
            if (!$wire instanceof Jid) { throw new ProtocolException('Group device JID became invalid.'); }
            if ($wire->isHostedPhoneNumber() || $wire->isHostedLid()) { continue; }
            if ($this->isExactOwnDevice($wire, $identity)) { continue; }
            $signal = $wire;
            if ($wire->isPhoneNumber()) {
                $base = (string) new Jid($wire->user(), $wire->server(), null, $wire->domainType());
                if (isset($mappings[$base])) {
                    $lid = Jid::parse($mappings[$base]);
                    if (!$lid instanceof Jid) { throw new ProtocolException('Group LID mapping became invalid.'); }
                    $signal = new Jid($lid->user(), $lid->server(), $wire->device(), $lid->domainType());
                }
            }
            $wireToSignal[(string) $wire] = (string) $signal; $recipients[] = (string) $wire;
        }
        sort($recipients, SORT_STRING);
        return [$wireToSignal, array_values(array_unique($recipients))];
    }

    private function senderAddress(string $mode, AccountIdentity $identity): string
    {
        $value = $mode === 'lid' ? $identity->lid() : $identity->id();
        $jid = $value === null ? null : Jid::parse($value);
        if (!$jid instanceof Jid) { throw new ProtocolException('Account identity does not support the group addressing mode.'); }
        return (string) $jid;
    }

    private function isExactOwnDevice(Jid $device, AccountIdentity $identity): bool
    {
        $me = Jid::parse($identity->id()); $lid = $identity->lid() === null ? null : Jid::parse($identity->lid());
        if (!$me instanceof Jid || $me->device() === null) { throw new ProtocolException('Paired account has no companion device ID.'); }
        return $device->device() === $me->device()
            && ($device->user() === $me->user() || ($lid instanceof Jid && $device->user() === $lid->user()));
    }

    /** @param array<string,string> $mappings */
    private function pinMappings(AccountTransactionInterface $tx, array $mappings): void
    {
        foreach ($mappings as $phone => $lid) { $this->pin($tx, 'pn_mapping', $phone, $lid); $this->pin($tx, 'lid_mapping', $lid, $phone); }
    }

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

    private function assertCredentialRevision(AccountTransactionInterface $tx): void
    {
        $current = (new TransactionalCredentialStore($this->accounts))->loadFromTransaction($tx);
        if (!$current instanceof CredentialRecord || $current->revision() !== $this->record->revision()) {
            throw new ProtocolException('Credentials changed before the group operation; rebuild the account session.');
        }
    }

    private function memoryKey(string $groupJid, string $deviceJid): string { return hash('sha256', $groupJid . "\0" . $deviceJid); }

    private function pad(string $plaintext): string
    {
        if ($plaintext === '' || strlen($plaintext) > 4194288) { throw new ProtocolException('Outgoing group plaintext is empty or too large.'); }
        $byte = $this->random->bytes(1);
        if (strlen($byte) !== 1) { throw new ProtocolException('Unable to generate group message padding.'); }
        $length = ord($byte) & 0x0f; if ($length === 0) { $length = 15; }
        return $plaintext . str_repeat(chr($length), $length);
    }

    /** @param list<string> $participants */
    private static function participantHash(array $participants): string
    {
        sort($participants, SORT_STRING);
        return '2:' . substr(base64_encode(hash('sha256', implode('', $participants), true)), 0, 6);
    }

    private function messageType(Message $message): string
    {
        if ($message->hasReactionMessage()) { return 'reaction'; }
        foreach (['hasPollCreationMessage', 'hasPollCreationMessageV2', 'hasPollCreationMessageV3', 'hasPollCreationMessageV4', 'hasPollCreationMessageV5', 'hasPollUpdateMessage'] as $method) {
            if (method_exists($message, $method) && $message->{$method}()) { return 'poll'; }
        }
        foreach (['hasImageMessage', 'hasVideoMessage', 'hasAudioMessage', 'hasDocumentMessage', 'hasStickerMessage'] as $method) {
            if (method_exists($message, $method) && $message->{$method}()) { return 'media'; }
        }
        return 'text';
    }
}
