<?php

declare(strict_types=1);

namespace PhpWA\Client;

use InvalidArgumentException;
use PhpWA\Auth\AccountIdentity;
use PhpWA\Crypto\RandomSourceInterface;
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\Protocol\Binary\BinaryNode;
use PhpWA\Protocol\Jid\Jid;
use PhpWA\Protocol\Proto\Message;
use PhpWA\Protocol\Proto\Message\DeviceSentMessage;
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;

/** Bounded outgoing-message recovery for authenticated retry receipts. */
final class OutgoingRetryReceiptProcessor
{
    /** @var string */ private $accountId;
    /** @var ProtocolConnectionInterface */ private $connection;
    /** @var AccountStoreInterface */ private $accounts;
    /** @var CredentialRecord */ private $record;
    /** @var PreKeyDirectoryInterface */ private $preKeys;
    /** @var OutgoingStanzaStore */ private $outgoing;
    /** @var RandomSourceInterface */ private $random;
    /** @var ProtobufCodec */ private $protobuf;
    /** @var callable */ private $clock;

    public function __construct(
        string $accountId,
        ProtocolConnectionInterface $connection,
        AccountStoreInterface $accounts,
        CredentialRecord $record,
        PreKeyDirectoryInterface $preKeys,
        ?OutgoingStanzaStore $outgoing = null,
        ?RandomSourceInterface $random = null,
        ?callable $clock = null
    ) {
        if ($accountId === '' || strlen($accountId) > 512 || preg_match('//u', $accountId) !== 1
            || preg_match('/[\x00-\x1f\x7f]/', $accountId) === 1) {
            throw new InvalidArgumentException('Outgoing retry processor account ID is invalid.');
        }
        $this->accountId = $accountId; $this->connection = $connection; $this->accounts = $accounts; $this->record = $record;
        $this->preKeys = $preKeys; $this->outgoing = $outgoing ?? new OutgoingStanzaStore($accounts);
        $this->random = $random ?? new SystemRandomSource(); $this->protobuf = new ProtobufCodec(4194304);
        $this->clock = $clock ?? static function (): int { return time(); };
    }

    public function process(BinaryNode $receipt, Deadline $deadline): bool
    {
        if ($receipt->tag() !== 'receipt' || $receipt->attribute('type') !== 'retry') { return false; }
        $parsed = $this->parse($receipt); $stored = $this->outgoing->retryMessage($this->accountId, $parsed['id']);
        if (!$stored instanceof StoredOutgoingMessage) { throw new ProtocolException('Retry receipt references an unavailable outgoing message.'); }
        $originalStanza = $this->outgoing->findForRetry($this->accountId, $parsed['id']);
        if (!$originalStanza instanceof BinaryNode) { throw new ProtocolException('Retry receipt references unavailable outgoing ciphertext metadata.'); }
        $this->assertConversation($stored, $parsed);

        $existing = $this->accounts->transaction($this->accountId, static function (AccountTransactionInterface $tx) use ($parsed) {
            return $tx->get('outgoing_retry', hash('sha256', $parsed['sender'] . "\0" . $parsed['id']));
        });
        if ($existing !== null) {
            try { $meta = json_decode($existing->value(), true, 8, JSON_THROW_ON_ERROR); }
            catch (\Throwable $error) { throw new ProtocolException('Stored outgoing retry counter is malformed.', 0, $error); }
            if (!is_array($meta) || !isset($meta['count']) || !is_int($meta['count'])) { throw new ProtocolException('Stored outgoing retry counter is invalid.'); }
            if ($meta['count'] >= $parsed['count']) {
                if ($meta['count'] === $parsed['count']) { $this->connection->send($originalStanza, $deadline); }
                $this->connection->send($this->ack($receipt, $parsed['id']), $deadline);
                return true;
            }
        }

        $preflight = $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $tx) use ($parsed): array {
                $this->assertCredentials($tx);
                $signalJid = $this->signalAddress($tx, $parsed['sender']);
                $hasSession = (new TransactionalSignalStore($tx, $this->record->credentials()))->loadSession($signalJid) !== null;
                return ['signal_jid' => $signalJid, 'has_session' => $hasSession];
            }
        );
        $bundle = $receipt->child('keys') instanceof BinaryNode ? (new PreKeyBundleParser())->parse($receipt) : null;
        if (!$bundle instanceof PreKeyBundle && (!$preflight['has_session'] || $parsed['count'] >= 2)) {
            $bundles = $this->preKeys->fetch([$preflight['signal_jid']], $deadline);
            $bundle = $bundles[$preflight['signal_jid']] ?? null;
            if (!$bundle instanceof PreKeyBundle) { throw new ProtocolException('Retry session recreation did not return a pre-key bundle.'); }
        }
        $now = ($this->clock)();
        if (!is_int($now) || $now < 0) { throw new ProtocolException('Outgoing retry clock returned an invalid timestamp.'); }
        $resend = $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $tx) use ($parsed, $stored, $originalStanza, $preflight, $bundle, $now): BinaryNode {
                $this->assertCredentials($tx);
                $signal = new TransactionalSignalStore($tx, $this->record->credentials());
                if ($bundle instanceof PreKeyBundle) { (new SessionBuilder($signal, $preflight['signal_jid']))->initializeOutgoing($bundle); }
                if ($signal->loadSession($preflight['signal_jid']) === null) { throw new ProtocolException('No Signal session is available for outgoing retry.'); }
                $message = $this->retryPlaintext($signal, $stored, $parsed);
                $ciphertext = (new SessionCipher($signal, $preflight['signal_jid']))->encrypt($this->pad($this->protobuf->encode($message)));
                $type = $ciphertext->type() === SignalCiphertext::PREKEY ? 'pkmsg' : 'msg';
                $enc = BinaryNode::bytes('enc', ['v' => '2', 'type' => $type, 'count' => (string) $parsed['count']], $ciphertext->body());
                $attrs = [
                    'id' => $parsed['id'], 'type' => $originalStanza->attribute('type') ?? 'text',
                    'to' => $stored->isGroup() ? $stored->to() : $parsed['sender'], 't' => (string) $parsed['timestamp'],
                ];
                if ($stored->isGroup()) { $attrs['participant'] = $parsed['sender']; }
                else {
                    $attrs['device_fanout'] = 'false';
                    if ($parsed['sender_is_own']) { $attrs['recipient'] = $stored->to(); }
                }
                $children = [$enc];
                if ($type === 'pkmsg') { $children[] = BinaryNode::bytes('device-identity', [], (string) $this->record->credentials()->signedDeviceIdentity()); }
                $node = BinaryNode::children('message', $attrs, $children);
                $this->outgoing->stageRetryInTransaction($tx, $parsed['id'], $node);
                $retryKey = hash('sha256', $parsed['sender'] . "\0" . $parsed['id']);
                $known = $tx->get('outgoing_retry', $retryKey);
                $json = json_encode(['v' => 1, 'count' => $parsed['count'], 'sender' => $parsed['sender'], 'updated_at' => $now], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
                $tx->put('outgoing_retry', $retryKey, $json, $known === null ? 0 : $known->revision());
                return $node;
            }
        );
        try { $this->connection->send($resend, $deadline); }
        catch (\Throwable $error) { throw new OutgoingMessagePendingException($parsed['id'], $error); }
        $this->connection->send($this->ack($receipt, $parsed['id']), $deadline);
        return true;
    }

    /** @param array<string,mixed> $parsed */
    private function retryPlaintext(TransactionalSignalStore $signal, StoredOutgoingMessage $stored, array $parsed): Message
    {
        $message = new Message(); $message->mergeFromString($stored->message()->serializeToString());
        if ($stored->isGroup()) {
            $identity = $this->record->credentials()->me();
            if (!$identity instanceof AccountIdentity) { throw new ProtocolException('Paired identity is unavailable for group retry.'); }
            $sender = $identity->lid() ?? $identity->id();
            $name = SenderKeyName::compose($stored->to(), $sender);
            $distribution = (new GroupSessionBuilder($signal, $this->random))->create($name);
            $message->setSenderKeyDistributionMessage(new SenderKeyDistributionMessage([
                'groupId' => $stored->to(), 'axolotlSenderKeyDistributionMessage' => $distribution,
            ]));
        } elseif ($parsed['sender_is_own']) {
            $wrapper = new Message();
            $wrapper->setDeviceSentMessage(new DeviceSentMessage(['destinationJid' => $stored->to(), 'message' => $message]));
            $message = $wrapper;
        }
        return $message;
    }

    /** @return array{id:string,count:int,timestamp:int,sender:string,chat:string,sender_is_own:bool} */
    private function parse(BinaryNode $receipt): array
    {
        $id = $this->messageId($receipt->attribute('id')); $from = Jid::parse($receipt->attribute('from'));
        if (!$from instanceof Jid) { throw new ProtocolException('Retry receipt source JID is invalid.'); }
        $retry = $receipt->child('retry');
        if (!$retry instanceof BinaryNode || $this->messageId($retry->attribute('id')) !== $id) {
            throw new ProtocolException('Retry receipt child is missing or references another message.');
        }
        $count = $this->decimal($retry->attribute('count'), 'retry count', 5, false);
        $timestamp = $this->decimal($retry->attribute('t'), 'retry timestamp', PHP_INT_MAX, true);
        $sender = $from; $chat = $from;
        if ($from->isGroup()) {
            $sender = Jid::parse($receipt->attribute('participant'));
            if (!$sender instanceof Jid || $sender->isGroup()) { throw new ProtocolException('Group retry receipt participant is invalid.'); }
        }
        $identity = $this->record->credentials()->me();
        if (!$identity instanceof AccountIdentity) { throw new ProtocolException('Paired identity is unavailable for retry processing.'); }
        $senderIsOwn = $this->sameAccount($sender, $identity);
        return ['id' => $id, 'count' => $count, 'timestamp' => $timestamp, 'sender' => (string) $sender, 'chat' => (string) $chat, 'sender_is_own' => $senderIsOwn];
    }

    /** @param array<string,mixed> $parsed */
    private function assertConversation(StoredOutgoingMessage $stored, array $parsed): void
    {
        if ($stored->isGroup()) {
            if (!hash_equals($stored->to(), $parsed['chat'])) { throw new ProtocolException('Retry receipt group does not match the outgoing message.'); }
            return;
        }
        if (!$parsed['sender_is_own']) {
            $sender = Jid::parse($parsed['sender']); $target = Jid::parse($stored->to());
            if (!$sender instanceof Jid || !$target instanceof Jid || !$sender->sameUser($target)) {
                // PN and LID have different user components; the pinned mapping
                // is checked again when the Signal address is resolved.
                $validMapping = $this->accounts->transaction($this->accountId, static function (AccountTransactionInterface $tx) use ($sender, $target): bool {
                    if (!$sender instanceof Jid || !$target instanceof Jid) { return false; }
                    $senderBase = (string) new Jid($sender->user(), $sender->server(), null, $sender->domainType());
                    $targetBase = (string) new Jid($target->user(), $target->server(), null, $target->domainType());
                    $mapping = $tx->get('pn_mapping', $targetBase) ?? $tx->get('lid_mapping', $targetBase);
                    return $mapping !== null && hash_equals($mapping->value(), $senderBase);
                });
                if (!$validMapping) { throw new ProtocolException('Retry receipt sender does not match the outgoing conversation.'); }
            }
        }
    }

    private function signalAddress(AccountTransactionInterface $tx, string $wireText): string
    {
        $wire = Jid::parse($wireText);
        if (!$wire instanceof Jid) { throw new ProtocolException('Retry sender JID became invalid.'); }
        if ($wire->isPhoneNumber() || $wire->isHostedPhoneNumber()) {
            $base = (string) new Jid($wire->user(), $wire->server(), null, $wire->domainType());
            $mapping = $tx->get('pn_mapping', $base);
            if ($mapping !== null) {
                $lid = Jid::parse($mapping->value());
                if (!$lid instanceof Jid || (!$lid->isLid() && !$lid->isHostedLid())) { throw new ProtocolException('Pinned retry LID mapping is malformed.'); }
                return (string) new Jid($lid->user(), $lid->server(), $wire->device(), $lid->domainType());
            }
        }
        return (string) $wire;
    }

    private function assertCredentials(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 outgoing retry processing.');
        }
    }

    private function sameAccount(Jid $jid, AccountIdentity $identity): bool
    {
        foreach ([$identity->id(), $identity->lid()] as $value) {
            $own = $value === null ? null : Jid::parse($value);
            if ($own instanceof Jid && $jid->sameUser($own)) { return true; }
        }
        return false;
    }

    private function ack(BinaryNode $receipt, string $id): BinaryNode
    {
        $attrs = ['class' => 'receipt', 'id' => $id, 'to' => (string) $receipt->attribute('from'), 'type' => 'retry'];
        foreach (['participant', 'recipient'] as $name) { $value = $receipt->attribute($name); if ($value !== null) { $attrs[$name] = $value; } }
        return new BinaryNode('ack', $attrs);
    }

    private function messageId(?string $id): string
    {
        if ($id === null || preg_match('/\A[A-Za-z0-9._:-]{1,256}\z/D', $id) !== 1) { throw new ProtocolException('Retry receipt message ID is invalid.'); }
        return $id;
    }

    private function decimal(?string $value, string $field, int $maximum, bool $zeroAllowed): int
    {
        if ($value === null || preg_match('/\A(?:0|[1-9][0-9]{0,18})\z/D', $value) !== 1
            || (!$zeroAllowed && $value === '0') || (float) $value > $maximum) {
            throw new ProtocolException('Retry receipt ' . $field . ' is invalid.');
        }
        return (int) $value;
    }

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