<?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\PreKeyBundle;
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\DeviceSentMessage;
use PhpWA\Protocol\ProtobufCodec;
use PhpWA\Runtime\Deadline;
use PhpWA\Storage\AccountStoreInterface;
use PhpWA\Storage\AccountTransactionInterface;
use PhpWA\Storage\CredentialRecord;
use PhpWA\Storage\TransactionalCredentialStore;

/**
 * Direct-message fan-out with one atomic boundary around all Signal ratchets
 * and the exact replayable WABinary stanza.
 */
final class DirectMessageSender
{
    /** @var string */ private $accountId;
    /** @var ProtocolConnectionInterface */ private $connection;
    /** @var AccountStoreInterface */ private $accounts;
    /** @var CredentialRecord */ private $record;
    /** @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,
        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('Outgoing sender account ID is invalid.');
        }
        $this->accountId = $accountId; $this->connection = $connection; $this->accounts = $accounts; $this->record = $record;
        $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 $to, string $text, Deadline $deadline): OutgoingMessageResult
    {
        if ($text === '' || strlen($text) > 1048576 || preg_match('//u', $text) !== 1) {
            throw new InvalidArgumentException('Outgoing text is empty, malformed or too large.');
        }
        return $this->send($to, new Message(['conversation' => $text]), $deadline);
    }

    public function send(string $to, 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.');
        }
        $target = $this->target($to);
        $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 messages.');
        }
        $ownBase = $this->ownBaseFor($target, $identity);
        $discovery = $this->devices->devices([(string) $target, (string) $ownBase], $deadline);
        [$wireToSignal, $recipients] = $this->resolveRecipients($discovery, $target, $identity);
        if ($recipients === []) { throw new ProtocolException('Device discovery produced no message recipients.'); }

        $missing = $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $tx) use ($credentials, $wireToSignal): array {
                $this->assertCredentialRevision($tx);
                $signal = new TransactionalSignalStore($tx, $credentials);
                $missing = [];
                foreach ($wireToSignal as $signalJid) {
                    if ($signal->loadSession($signalJid) === null) { $missing[$signalJid] = true; }
                }
                return array_keys($missing);
            }
        );
        $bundles = $missing === [] ? [] : $this->preKeys->fetch($missing, $deadline);
        $id = $this->ids->generate();
        $phash = self::participantHash($recipients);
        $stanza = $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $tx) use ($credentials, $discovery, $wireToSignal, $recipients, $bundles, $message, $target, $id, $phash): BinaryNode {
                $this->assertCredentialRevision($tx);
                $this->pinMappings($tx, $discovery->phoneToLid());
                $signal = new TransactionalSignalStore($tx, $credentials);
                foreach ($wireToSignal as $signalJid) {
                    if ($signal->loadSession($signalJid) !== null) { continue; }
                    $bundle = $bundles[$signalJid] ?? null;
                    if (!$bundle instanceof PreKeyBundle) { throw new SignalException('A required remote pre-key bundle is unavailable.'); }
                    (new SessionBuilder($signal, $signalJid))->initializeOutgoing($bundle);
                }
                $plain = $this->protobuf->encode($message);
                if ($plain === '') { throw new ProtocolException('Outgoing protobuf message is empty.'); }
                $deviceSent = new Message();
                $deviceSent->setDeviceSentMessage((new DeviceSentMessage())->setDestinationJid((string) $target)->setMessage($message));
                $ownPlain = $this->protobuf->encode($deviceSent);
                $participants = [];
                $includeIdentity = false;
                foreach ($recipients as $wireJid) {
                    $payload = $this->isOwnDevice($wireJid, $credentials->me()) ? $ownPlain : $plain;
                    $ciphertext = (new SessionCipher($signal, $wireToSignal[$wireJid]))->encrypt($this->pad($payload));
                    $type = $ciphertext->type() === SignalCiphertext::PREKEY ? 'pkmsg' : 'msg';
                    if ($type === 'pkmsg') { $includeIdentity = true; }
                    $participants[] = BinaryNode::children('to', ['jid' => $wireJid], [
                        // Direct-flow whatsmeow does not put participant hash
                        // into the wire stanza; it is returned for diagnostics.
                        BinaryNode::bytes('enc', ['v' => '2', 'type' => $type], $ciphertext->body()),
                    ]);
                }
                $children = [BinaryNode::children('participants', [], $participants)];
                if ($includeIdentity) {
                    $children[] = BinaryNode::bytes('device-identity', [], (string) $credentials->signedDeviceIdentity());
                }
                $node = BinaryNode::children('message', [
                    'id' => $id, 'to' => (string) $target, 'type' => $this->messageType($message),
                    // В direct-flow addressing context определяется JID и
                    // списком устройств; phash относится к каждому enc,
                    // а не к внешнему message (как в Baileys/whatsmeow).
                ], $children);
                $this->outgoing->stageInTransaction($tx, $id, $node);
                $this->outgoing->stageMessageInTransaction($tx, $id, (string) $target, false, $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));
    }

    public function credentialRecord(): CredentialRecord { return $this->record; }

    private function target(string $value): Jid
    {
        $jid = Jid::parse($value);
        if (!$jid instanceof Jid || (!$jid->isPhoneNumber() && !$jid->isLid())
            || preg_match('/^[1-9][0-9]{0,19}$/D', $jid->user()) !== 1) {
            throw new InvalidArgumentException('Direct-message target must be a numeric phone or LID JID.');
        }
        return new Jid($jid->user(), $jid->server(), null, $jid->domainType());
    }


    private function ownBaseFor(Jid $target, AccountIdentity $identity): Jid
    {
        $value = ($target->isLid() || $target->isHostedLid()) ? $identity->lid() : $identity->id();
        $jid = $value === null ? null : Jid::parse($value);
        if (!$jid instanceof Jid) { throw new ProtocolException('The account has no identity for the requested addressing mode.'); }
        return new Jid($jid->user(), $jid->server(), null, $jid->domainType());
    }

    /** @return array{0:array<string,string>,1:list<string>} */
    private function resolveRecipients(DeviceDiscoveryResult $result, Jid $target, AccountIdentity $identity): array
    {
        $map = $result->phoneToLid(); $wireToSignal = []; $recipients = [];
        foreach ($result->devices() as $wireText) {
            $wire = Jid::parse($wireText);
            if (!$wire instanceof Jid) { throw new ProtocolException('Discovered device JID became invalid.'); }
            if ($this->isExactOwnDevice($wire, $identity)) { continue; }
            if (!$this->belongsToConversation($wire, $target, $identity)) {
                throw new ProtocolException('Device discovery returned a device outside the requested conversation.');
            }
            $signal = $wire;
            if ($wire->isPhoneNumber() || $wire->isHostedPhoneNumber()) {
                $base = (string) new Jid($wire->user(), $wire->server(), null, $wire->domainType());
                if (isset($map[$base])) {
                    $lid = Jid::parse($map[$base]);
                    if (!$lid instanceof Jid) { throw new ProtocolException('Discovered 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 belongsToConversation(Jid $device, Jid $target, AccountIdentity $identity): bool
    {
        if ($device->user() === $target->user()) { return true; }
        $me = Jid::parse($identity->id()); $lid = $identity->lid() === null ? null : Jid::parse($identity->lid());
        return ($me instanceof Jid && $device->user() === $me->user()) || ($lid instanceof Jid && $device->user() === $lid->user());
    }

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

    private function isOwnDevice(string $device, ?AccountIdentity $identity): bool
    {
        if (!$identity instanceof AccountIdentity) { return false; }
        $jid = Jid::parse($device); $me = Jid::parse($identity->id());
        $lid = $identity->lid() === null ? null : Jid::parse($identity->lid());
        return $jid instanceof Jid && (($me instanceof Jid && $jid->user() === $me->user())
            || ($lid instanceof Jid && $jid->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('Discovered 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 outgoing operation; rebuild the account session.');
        }
    }

    private function pad(string $plaintext): string
    {
        if ($plaintext === '' || strlen($plaintext) > 4194288) { throw new ProtocolException('Outgoing Signal plaintext is empty or too large.'); }
        $byte = $this->random->bytes(1);
        if (strlen($byte) !== 1) { throw new ProtocolException('Unable to generate Signal 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', 'hasContactMessage', 'hasContactsArrayMessage'] as $method) {
            if (method_exists($message, $method) && $message->{$method}()) { return 'media'; }
        }
        return 'text';
    }
}
