<?php

declare(strict_types=1);

namespace PhpWA\Crypto\Signal;

use PhpWA\Crypto\CryptoPrimitives;
use PhpWA\Exception\SignalException;
use PhpWA\Protocol\ProtobufCodec;
use PhpWA\Protocol\Proto\PreKeySignalMessage;
use PhpWA\Protocol\Proto\SignalMessage;

final class SessionCipher
{
    private const VERSION = 3;
    private const VERSION_BYTE = 0x33;
    private const MAX_MESSAGE_BYTES = 4194304;
    private const MAX_FUTURE_MESSAGES = 2000;

    /** @var SignalProtocolStoreInterface */ private $store;
    /** @var string */ private $address;
    /** @var SignalCurve */ private $curve;
    /** @var SignalSessionRecordCodec */ private $records;
    /** @var ProtobufCodec */ private $protobuf;

    public function __construct(SignalProtocolStoreInterface $store, string $address, ?SignalCurve $curve = null)
    {
        if ($address === '') { throw new \InvalidArgumentException('Signal address cannot be empty.'); }
        $this->store = $store;
        $this->address = $address;
        $this->curve = $curve ?? new SignalCurve();
        $this->records = new SignalSessionRecordCodec();
        $this->protobuf = new ProtobufCodec(self::MAX_MESSAGE_BYTES);
    }

    public function encrypt(string $plaintext): SignalCiphertext
    {
        if (strlen($plaintext) > self::MAX_MESSAGE_BYTES) { throw new SignalException('Signal plaintext exceeds its size limit.'); }
        $stored = $this->store->loadSession($this->address);
        if ($stored === null) { throw new SignalException('No Signal session exists for the remote device.'); }
        $record = $this->records->copy($stored);
        $session = $record->openSession();
        if ($session === null) { throw new SignalException('No open Signal session exists for the remote device.'); }
        $ourIdentity = $this->store->identityKeyPair();
        $chain = $session->chain($session->ratchet()->ephemeralKeyPair()->publicKey());
        if ($chain === null || $chain->type() !== SignalChain::SENDING) { throw new SignalException('Signal sending chain is unavailable.'); }
        self::fillMessageKeys($chain, $chain->counter() + 1);
        $messageKey = $chain->removeMessageKey($chain->counter());
        if ($messageKey === null) { throw new SignalException('Signal sending message key is unavailable.'); }
        $keys = SignalKdf::derive($messageKey, str_repeat("\0", 32), 'WhisperMessageKeys', 3);

        $message = new SignalMessage();
        $message->setRatchetKey($session->ratchet()->ephemeralKeyPair()->publicKey());
        $message->setCounter($chain->counter());
        $message->setPreviousCounter($session->ratchet()->previousCounter());
        $message->setCiphertext(CryptoPrimitives::aesCbcEncrypt($keys[0], $plaintext, substr($keys[2], 0, 16)));
        $messageBytes = $this->protobuf->encode($message);
        $macInput = $ourIdentity->publicKey() . $session->index()->remoteIdentityKey() . chr(self::VERSION_BYTE) . $messageBytes;
        $body = chr(self::VERSION_BYTE) . $messageBytes . substr(CryptoPrimitives::hmacSha256($keys[1], $macInput), 0, 8);
        $record->trim();
        $this->store->storeSession($this->address, $record);

        $pending = $session->pendingPreKey();
        if ($pending === null) { return new SignalCiphertext(SignalCiphertext::WHISPER, $body); }
        $preKey = new PreKeySignalMessage();
        $preKey->setIdentityKey($ourIdentity->publicKey());
        $preKey->setRegistrationId($this->store->registrationId());
        $preKey->setBaseKey($pending['base_key']);
        $preKey->setSignedPreKeyId($pending['signed_pre_key_id']);
        $preKey->setMessage($body);
        if (array_key_exists('pre_key_id', $pending)) { $preKey->setPreKeyId($pending['pre_key_id']); }
        return new SignalCiphertext(SignalCiphertext::PREKEY, chr(self::VERSION_BYTE) . $this->protobuf->encode($preKey));
    }

    public function decryptPreKeyMessage(string $body): string
    {
        self::assertWireMessage($body, 'pre-key');
        $preKey = $this->protobuf->decode(PreKeySignalMessage::class, substr($body, 1));
        if (!$preKey instanceof PreKeySignalMessage || !$preKey->hasRegistrationId() || !$preKey->hasSignedPreKeyId() || !$preKey->hasBaseKey() || !$preKey->hasIdentityKey() || !$preKey->hasMessage()) {
            throw new SignalException('Signal pre-key message is missing required fields.');
        }
        SignalCurve::assertPublicKey($preKey->getBaseKey());
        SignalCurve::assertPublicKey($preKey->getIdentityKey());
        $stored = $this->store->loadSession($this->address) ?? new SignalSessionRecord();
        $record = $this->records->copy($stored);
        $builder = new SessionBuilder($this->store, $this->address, $this->curve);
        $preKeyId = $builder->initializeIncoming($record, $preKey);
        $session = $record->sessionByBaseKey($preKey->getBaseKey());
        if ($session === null) { throw new SignalException('Signal responder session was not created.'); }
        $plaintext = $this->decryptWithSession($preKey->getMessage(), $session);
        $this->store->storeSession($this->address, $record);
        if ($preKeyId !== null) { $this->store->removePreKey($preKeyId); }
        return $plaintext;
    }

    public function decryptMessage(string $body): string
    {
        self::assertWireMessage($body, 'message');
        $stored = $this->store->loadSession($this->address);
        if ($stored === null) { throw new SignalException('No Signal session record exists for the remote device.'); }
        $count = count($stored->sessions());
        for ($index = 0; $index < $count; $index++) {
            $candidate = $this->records->copy($stored);
            $session = $candidate->sessions()[$index];
            try {
                $plaintext = $this->decryptWithSession($body, $session);
                $this->store->storeSession($this->address, $candidate);
                return $plaintext;
            } catch (SignalException $error) {
                // Each attempt uses a deep copy, so a failed MAC cannot advance
                // either the selected session or another archived session.
            }
        }
        throw new SignalException('No stored Signal session could authenticate the message.');
    }

    private function decryptWithSession(string $body, SignalSession $session): string
    {
        self::assertWireMessage($body, 'message');
        if (strlen($body) < 10) { throw new SignalException('Signal message is truncated.'); }
        $protobufBytes = substr($body, 1, -8);
        $message = $this->protobuf->decode(SignalMessage::class, $protobufBytes);
        if (!$message instanceof SignalMessage || !$message->hasRatchetKey() || !$message->hasCounter() || !$message->hasPreviousCounter() || !$message->hasCiphertext()) {
            throw new SignalException('Signal message is missing required fields.');
        }
        $ratchetKey = $message->getRatchetKey();
        SignalCurve::assertPublicKey($ratchetKey);
        $ciphertext = $message->getCiphertext();
        if ($ciphertext === '' || (strlen($ciphertext) % 16) !== 0) { throw new SignalException('Signal ciphertext length is invalid.'); }
        $this->maybeStepRatchet($session, $ratchetKey, $message->getPreviousCounter());
        $chain = $session->chain($ratchetKey);
        if ($chain === null || $chain->type() !== SignalChain::RECEIVING) { throw new SignalException('Signal receiving chain is unavailable.'); }
        self::fillMessageKeys($chain, $message->getCounter());
        $messageKey = $chain->removeMessageKey($message->getCounter());
        if ($messageKey === null) { throw new SignalException('Signal message key was already used or was never derived.'); }
        $keys = SignalKdf::derive($messageKey, str_repeat("\0", 32), 'WhisperMessageKeys', 3);
        $ourIdentity = $this->store->identityKeyPair();
        $macInput = $session->index()->remoteIdentityKey() . $ourIdentity->publicKey() . chr(self::VERSION_BYTE) . $protobufBytes;
        $expectedMac = substr(CryptoPrimitives::hmacSha256($keys[1], $macInput), 0, 8);
        if (!hash_equals($expectedMac, substr($body, -8))) { throw new SignalException('Signal message authentication failed.'); }
        try {
            $plaintext = CryptoPrimitives::aesCbcDecrypt($keys[0], $ciphertext, substr($keys[2], 0, 16));
        } catch (\Throwable $error) {
            throw new SignalException('Signal message decryption failed.', 0, $error);
        }
        $session->setPendingPreKey(null);
        return $plaintext;
    }

    private static function fillMessageKeys(SignalChain $chain, int $target): void
    {
        if ($chain->counter() >= $target) { return; }
        if ($target - $chain->counter() > self::MAX_FUTURE_MESSAGES) { throw new SignalException('Signal message counter is too far in the future.'); }
        while ($chain->counter() < $target) {
            $chainKey = $chain->chainKey();
            if ($chainKey === null) { throw new SignalException('Signal chain is closed.'); }
            $nextCounter = $chain->counter() + 1;
            $chain->putMessageKey($nextCounter, CryptoPrimitives::hmacSha256($chainKey, "\x01"));
            $chain->advance(CryptoPrimitives::hmacSha256($chainKey, "\x02"));
        }
    }

    private function maybeStepRatchet(SignalSession $session, string $remoteKey, int $previousCounter): void
    {
        if ($session->chain($remoteKey) !== null) { return; }
        $ratchet = $session->ratchet();
        $previous = $session->chain($ratchet->lastRemoteEphemeralKey());
        if ($previous !== null) { self::fillMessageKeys($previous, $previousCounter); $previous->close(); }
        $this->calculateRatchet($session, $remoteKey, false);
        $oldSending = $session->chain($ratchet->ephemeralKeyPair()->publicKey());
        if ($oldSending !== null) { $ratchet->setPreviousCounter(max(0, $oldSending->counter())); $session->removeChain($ratchet->ephemeralKeyPair()->publicKey()); }
        $ratchet->setEphemeralKeyPair($this->curve->generateKeyPair());
        $this->calculateRatchet($session, $remoteKey, true);
        $ratchet->setLastRemoteEphemeralKey($remoteKey);
    }

    private function calculateRatchet(SignalSession $session, string $remoteKey, bool $sending): void
    {
        $ratchet = $session->ratchet();
        $master = SignalKdf::derive($this->curve->agreement($remoteKey, $ratchet->ephemeralKeyPair()->privateKey()), $ratchet->rootKey(), 'WhisperRatchet', 2);
        $chainPublic = $sending ? $ratchet->ephemeralKeyPair()->publicKey() : $remoteKey;
        $session->putChain($chainPublic, new SignalChain(-1, $master[1], $sending ? SignalChain::SENDING : SignalChain::RECEIVING));
        $ratchet->setRootKey($master[0]);
    }

    private static function assertWireMessage(string $body, string $kind): void
    {
        if ($body === '' || strlen($body) > self::MAX_MESSAGE_BYTES) { throw new SignalException('Signal ' . $kind . ' is empty or too large.'); }
        $version = ord($body[0]);
        if (($version >> 4) < self::VERSION || ($version & 0x0f) > self::VERSION) { throw new SignalException('Signal ' . $kind . ' uses an incompatible protocol version.'); }
    }
}
