<?php

declare(strict_types=1);

namespace PhpWA\Client;

use InvalidArgumentException;
use PhpWA\AppState\AppStateKeyStore;
use PhpWA\Crypto\KeyPair;
use PhpWA\Crypto\PreKey;
use PhpWA\Crypto\PreKeyFactory;
use PhpWA\Crypto\Signal\SignalKeyPair;
use PhpWA\Crypto\Signal\TransactionalSignalStore;
use PhpWA\Exception\ProtocolException;
use PhpWA\Exception\SignalException;
use PhpWA\Integration\EventType;
use PhpWA\Protocol\Binary\BinaryNode;
use PhpWA\Protocol\Proto\Message;
use PhpWA\Runtime\Deadline;
use PhpWA\Storage\AccountStoreInterface;
use PhpWA\Storage\AccountTransactionInterface;
use PhpWA\Storage\CredentialRecord;
use PhpWA\Storage\RetryDecision;
use PhpWA\Storage\TransactionalCredentialStore;

/**
 * Enforces decrypt -> ratchet -> dedupe -> outbox -> COMMIT -> receipt.
 * Decryption failures leave no ratchet mutation and request bounded retries.
 */
final class IncomingMessageProcessor
{
    /** @var string */ private $accountId;
    /** @var ProtocolConnectionInterface */ private $connection;
    /** @var AccountStoreInterface */ private $accounts;
    /** @var TransactionalCredentialStore */ private $credentialStore;
    /** @var CredentialRecord */ private $record;
    /** @var IncomingMessageDecryptorInterface */ private $decryptor;
    /** @var MessageReceiptBuilder */ private $receipts;
    /** @var RetryReceiptBuilder */ private $retries;
    /** @var PreKeyFactory */ private $preKeys;
    /** @var callable */ private $clock;
    /** @var int */ private $maximumRetries;
    /** @var MessageProjector */ private $projector;

    public function __construct(
        string $accountId,
        ProtocolConnectionInterface $connection,
        AccountStoreInterface $accounts,
        CredentialRecord $record,
        ?IncomingMessageDecryptorInterface $decryptor = null,
        ?MessageReceiptBuilder $receipts = null,
        ?RetryReceiptBuilder $retries = null,
        ?PreKeyFactory $preKeys = null,
        ?callable $clock = null,
        int $maximumRetries = 5,
        ?MessageProjector $projector = null
    ) {
        if ($accountId === '' || strlen($accountId) > 512 || preg_match('//u', $accountId) !== 1
            || preg_match('/[\x00-\x1f\x7f]/', $accountId) === 1 || $maximumRetries < 1 || $maximumRetries > 10) {
            throw new InvalidArgumentException('Incoming processor account or retry bounds are invalid.');
        }
        $this->accountId = $accountId; $this->connection = $connection; $this->accounts = $accounts;
        $this->credentialStore = new TransactionalCredentialStore($accounts); $this->record = $record;
        $this->decryptor = $decryptor ?? new SignalIncomingMessageDecryptor();
        $this->receipts = $receipts ?? new MessageReceiptBuilder(); $this->retries = $retries ?? new RetryReceiptBuilder();
        $this->preKeys = $preKeys ?? new PreKeyFactory(); $this->clock = $clock ?? static function (): int { return time(); };
        $this->maximumRetries = $maximumRetries;
        $this->projector = $projector ?? new MessageProjector();
    }

    public function process(BinaryNode $stanza, Deadline $deadline): IncomingMessageResult
    {
        $envelope = IncomingMessageEnvelope::parse($stanza, $this->record->credentials());
        $now = ($this->clock)();
        if (!is_int($now) || $now < 0) { throw new ProtocolException('Incoming processor clock returned an invalid timestamp.'); }
        try {
            $status = $this->accounts->transaction(
                $this->accountId,
                function (AccountTransactionInterface $transaction) use ($envelope, $now): string {
                    if ($transaction->hasIncoming($envelope->id())) { return IncomingMessageResult::DUPLICATE; }
                    $decrypted = $this->decryptor->decrypt($transaction, $this->record->credentials(), $envelope);
                    $this->stageHistorySyncJob($transaction, $envelope, $decrypted, $now);
                    $this->storeAppStateKeys($transaction, $decrypted);
                    $payload = $this->eventPayload($envelope, $decrypted);
                    if (!$transaction->stageIncoming($envelope->id(), EventType::MESSAGE_RECEIVED, $payload, $now)) {
                        return IncomingMessageResult::DUPLICATE;
                    }
                    return IncomingMessageResult::COMMITTED;
                }
            );
            // Both a new commit and a previously committed duplicate are safe
            // to acknowledge. A duplicate is never decrypted a second time.
            $this->connection->send($this->receipts->delivery($envelope, $this->record->credentials()), $deadline);
            return new IncomingMessageResult($status);
        } catch (SignalException | ProtocolException $error) {
            return $this->requestRetry($envelope, $now, $deadline);
        }
    }

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

    private function requestRetry(IncomingMessageEnvelope $envelope, int $now, Deadline $deadline): IncomingMessageResult
    {
        $outcome = $this->accounts->transaction(
            $this->accountId,
            function (AccountTransactionInterface $transaction) use ($envelope, $now): array {
                $decision = $transaction->registerRetry($envelope->id(), $now, $this->maximumRetries, 2);
                $material = null;
                $record = null;
                if ($decision->shouldSend() && $decision->attempt() > 1) {
                    $record = $this->credentialStore->loadFromTransaction($transaction);
                    if (!$record instanceof CredentialRecord) {
                        throw new ProtocolException('Credentials disappeared while preparing a retry key bundle.');
                    }
                    [$material, $record] = $this->prepareRetryKey($transaction, $record);
                }
                return ['decision' => $decision, 'material' => $material, 'record' => $record];
            }
        );
        /** @var RetryDecision $decision */
        $decision = $outcome['decision'];
        if ($outcome['record'] instanceof CredentialRecord) { $this->record = $outcome['record']; }
        if ($decision->shouldSend()) {
            $this->connection->send($this->retries->build(
                $envelope,
                $this->record->credentials(),
                $decision,
                $outcome['material'] instanceof RetryKeyMaterial ? $outcome['material'] : null
            ), $deadline);
            return new IncomingMessageResult(IncomingMessageResult::RETRY_SENT, $decision->attempt(), $decision->nextAllowedAt());
        }
        return new IncomingMessageResult(
            $decision->isExhausted() ? IncomingMessageResult::RETRY_EXHAUSTED : IncomingMessageResult::RETRY_DELAYED,
            $decision->attempt(),
            $decision->nextAllowedAt()
        );
    }

    /** @return array{0:RetryKeyMaterial,1:CredentialRecord} */
    private function prepareRetryKey(AccountTransactionInterface $transaction, CredentialRecord $record): array
    {
        $credentials = $record->credentials();
        $id = $credentials->firstUnuploadedPreKeyId();
        if ($id >= 16777215) { throw new ProtocolException('Signal pre-key ID space is exhausted.'); }
        $signalStore = new TransactionalSignalStore($transaction, $credentials);
        $stored = $id < $credentials->nextPreKeyId() ? $signalStore->loadPreKey($id) : null;
        if ($stored !== null) {
            $preKey = new PreKey($id, new KeyPair($stored->privateKey(), substr($stored->publicKey(), 1)));
            $next = $credentials->nextPreKeyId();
        } else {
            $preKey = $this->preKeys->preKey($id);
            $signalStore->storePreKey($id, new SignalKeyPair(
                PreKeyFactory::signalPublicKey($preKey->keyPair()->publicKey()),
                $preKey->keyPair()->privateKey()
            ));
            $next = max($credentials->nextPreKeyId(), $id + 1);
        }
        $updated = $credentials->withPreKeyCursor($next, $id + 1);
        $saved = $this->credentialStore->saveInTransaction($transaction, $updated, $record->revision());
        return [new RetryKeyMaterial($preKey), $saved];
    }

    private function eventPayload(IncomingMessageEnvelope $envelope, DecryptedIncomingMessage $decrypted): string
    {
        $message = $decrypted->message();
        $projection = $this->projector->project($message);
        $types = [];
        foreach ($envelope->parts() as $part) { $types[] = $part->type(); }
        $payload = [
            'schema_version' => 2,
            'id' => $envelope->id(),
            'chat_jid' => $envelope->chatJid(),
            'sender_jid' => $envelope->senderJid(),
            'participant_jid' => $envelope->participantJid(),
            // WhatsApp signed the alternate PN/LID address as part of the
            // encrypted envelope. Applications may use this pair as a strong
            // identity mapping, but must never infer a phone from a LID alone.
            'alternate_sender_jid' => $envelope->alternateSenderJid(),
            'from_me' => $envelope->isFromMe(),
            'timestamp' => $envelope->timestamp(),
            'push_name' => $envelope->pushName(),
            'category' => $envelope->category(),
            'offline' => $envelope->isOffline(),
            'kind' => $projection['kind'],
            'text' => $projection['text'],
            'media' => $projection['media'],
            'details' => $projection['details'],
            'wrappers' => $projection['wrappers'],
            'encryption_types' => $types,
            'message_proto_base64' => base64_encode($decrypted->protobufBytes()),
        ];
        try { $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); }
        catch (\Throwable $error) { throw new ProtocolException('Unable to serialize the incoming application event.', 0, $error); }
        if (!is_string($json)) { throw new ProtocolException('Unable to serialize the incoming application event.'); }
        // A history bootstrap may carry a multi-megabyte inline archive. The
        // complete notification is already stored in the encrypted history job;
        // keep the public outbox event inside its strict 4 MiB contract.
        if (strlen($json) > 4194304) {
            unset($payload['message_proto_base64']);
            $payload['message_proto_omitted'] = true;
            $payload['message_proto_sha256'] = hash('sha256', $decrypted->protobufBytes());
            try { $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); }
            catch (\Throwable $error) { throw new ProtocolException('Unable to serialize the bounded incoming application event.', 0, $error); }
            if (!is_string($json) || strlen($json) > 4194304) {
                throw new ProtocolException('Incoming application event exceeds its durable outbox limit.');
            }
        }
        return $json;
    }

    private function stageHistorySyncJob(
        AccountTransactionInterface $transaction,
        IncomingMessageEnvelope $envelope,
        DecryptedIncomingMessage $decrypted,
        int $now
    ): void {
        $message = $decrypted->message();
        if (!$message->hasProtocolMessage() || $message->getProtocolMessage() === null
            || !$message->getProtocolMessage()->hasHistorySyncNotification()
            || $message->getProtocolMessage()->getHistorySyncNotification() === null) {
            return;
        }
        $job = HistorySyncJob::fromNotification(
            $envelope->id(),
            $message->getProtocolMessage()->getHistorySyncNotification(),
            $now
        );
        $known = $transaction->get('history_job', $job->key());
        if ($known !== null) {
            if (!hash_equals($known->value(), $job->encode())) {
                throw new ProtocolException('History-sync job key conflicts with stored state.');
            }
            return;
        }
        $transaction->put('history_job', $job->key(), $job->encode(), 0);
    }

    private function storeAppStateKeys(AccountTransactionInterface $transaction, DecryptedIncomingMessage $decrypted): void
    {
        $message = $decrypted->message();
        if (!$message->hasProtocolMessage() || $message->getProtocolMessage() === null
            || !$message->getProtocolMessage()->hasAppStateSyncKeyShare()
            || $message->getProtocolMessage()->getAppStateSyncKeyShare() === null) {
            return;
        }
        (new AppStateKeyStore())->storeShare($transaction, $message->getProtocolMessage()->getAppStateSyncKeyShare());
    }

}
