<?php

declare(strict_types=1);

namespace PhpWA\Crypto\Signal;

use PhpWA\Exception\StorageException;

final class SignalSessionRecordCodec
{
    private const VERSION = 1;
    private const MAX_BYTES = 4194304;

    public function encode(SignalSessionRecord $record): string
    {
        $sessions = [];
        foreach ($record->sessions() as $session) { $sessions[] = $this->encodeSession($session); }
        try {
            $json = json_encode(['v' => self::VERSION, 'sessions' => $sessions], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
        } catch (\Throwable $error) {
            throw new StorageException('Unable to encode Signal session record.', 0, $error);
        }
        if (!is_string($json) || strlen($json) > self::MAX_BYTES) { throw new StorageException('Signal session record exceeds its size limit.'); }
        return $json;
    }

    public function decode(string $json): SignalSessionRecord
    {
        if ($json === '' || strlen($json) > self::MAX_BYTES) { throw new StorageException('Signal session record is empty or too large.'); }
        try { $data = json_decode($json, true, 128, JSON_THROW_ON_ERROR); }
        catch (\Throwable $error) { throw new StorageException('Signal session record is malformed.', 0, $error); }
        if (!is_array($data) || array_keys($data) !== ['v', 'sessions'] || $data['v'] !== self::VERSION || !is_array($data['sessions']) || !$this->isList($data['sessions']) || count($data['sessions']) > SignalSessionRecord::ARCHIVED_LIMIT + 1) {
            throw new StorageException('Signal session record shape is invalid.');
        }
        $sessions = [];
        try { foreach ($data['sessions'] as $session) { $sessions[] = $this->decodeSession($session); } }
        catch (StorageException $error) { throw $error; }
        catch (\Throwable $error) { throw new StorageException('Signal session record contains invalid state.', 0, $error); }
        return new SignalSessionRecord($sessions);
    }

    public function copy(SignalSessionRecord $record): SignalSessionRecord { return $this->decode($this->encode($record)); }

    /** @return array<string,mixed> */
    private function encodeSession(SignalSession $session): array
    {
        $ratchet = $session->ratchet(); $index = $session->index(); $chains = [];
        foreach ($session->chains() as $public => $chain) {
            $messageKeys = [];
            foreach ($chain->messageKeys() as $counter => $key) { $messageKeys[] = ['counter' => $counter, 'key' => base64_encode($key)]; }
            $chains[$public] = ['counter' => $chain->counter(), 'key' => $chain->chainKey() === null ? null : base64_encode($chain->chainKey()), 'type' => $chain->type(), 'message_keys' => $messageKeys];
        }
        $pending = $session->pendingPreKey();
        if ($pending !== null) { $pending['base_key'] = base64_encode($pending['base_key']); }
        return [
            'registration_id' => $session->registrationId(),
            'ratchet' => ['root_key' => base64_encode($ratchet->rootKey()), 'ephemeral_public' => base64_encode($ratchet->ephemeralKeyPair()->publicKey()), 'ephemeral_private' => base64_encode($ratchet->ephemeralKeyPair()->privateKey()), 'last_remote' => base64_encode($ratchet->lastRemoteEphemeralKey()), 'previous_counter' => $ratchet->previousCounter()],
            'index' => ['base_key' => base64_encode($index->baseKey()), 'base_key_type' => $index->baseKeyType(), 'remote_identity_key' => base64_encode($index->remoteIdentityKey()), 'closed' => $index->closed()],
            'chains' => $chains,
            'pending_pre_key' => $pending,
        ];
    }

    private function decodeSession($data): SignalSession
    {
        if (!is_array($data) || array_keys($data) !== ['registration_id', 'ratchet', 'index', 'chains', 'pending_pre_key'] || !is_int($data['registration_id']) || !is_array($data['ratchet']) || !is_array($data['index']) || !is_array($data['chains'])) {
            throw new StorageException('Signal session entry is malformed.');
        }
        $r = $data['ratchet']; $i = $data['index'];
        if (array_keys($r) !== ['root_key', 'ephemeral_public', 'ephemeral_private', 'last_remote', 'previous_counter'] || array_keys($i) !== ['base_key', 'base_key_type', 'remote_identity_key', 'closed']) { throw new StorageException('Signal ratchet/index shape is invalid.'); }
        $pair = new SignalKeyPair($this->b64($r['ephemeral_public'], 33), $this->b64($r['ephemeral_private'], 32));
        $ratchet = new SignalRatchet($this->b64($r['root_key'], 32), $pair, $this->b64($r['last_remote'], 33), $this->integer($r['previous_counter']));
        $index = new SignalSessionIndex($this->b64($i['base_key'], 33), $this->integer($i['base_key_type']), $this->b64($i['remote_identity_key'], 33), $this->integer($i['closed'], -1));
        if (count($data['chains']) > 32) { throw new StorageException('Signal session contains too many chains.'); }
        $chains = [];
        foreach ($data['chains'] as $public => $chain) {
            if (!is_string($public) || !is_array($chain) || array_keys($chain) !== ['counter', 'key', 'type', 'message_keys'] || !is_array($chain['message_keys']) || !$this->isList($chain['message_keys']) || count($chain['message_keys']) > 2000) { throw new StorageException('Signal chain entry is malformed.'); }
            $pub = $this->b64($public, 33); SignalCurve::assertPublicKey($pub);
            $keys = [];
            foreach ($chain['message_keys'] as $messageKey) {
                if (!is_array($messageKey) || array_keys($messageKey) !== ['counter', 'key'] || !is_int($messageKey['counter']) || $messageKey['counter'] < 0 || $messageKey['counter'] > 0xffffffff || isset($keys[$messageKey['counter']])) { throw new StorageException('Signal message-key counter is malformed.'); }
                $keys[$messageKey['counter']] = $this->b64($messageKey['key'], 32);
            }
            $chains[$public] = new SignalChain($this->integer($chain['counter'], -1), $chain['key'] === null ? null : $this->b64($chain['key'], 32), $this->integer($chain['type']), $keys);
        }
        $pending = $data['pending_pre_key'];
        if ($pending !== null) {
            if (!is_array($pending) || !isset($pending['base_key'])) { throw new StorageException('Signal pending pre-key is malformed.'); }
            $pending['base_key'] = $this->b64($pending['base_key'], 33);
        }
        return new SignalSession($data['registration_id'], $ratchet, $index, $chains, $pending);
    }

    private function b64($value, int $length): string
    {
        if (!is_string($value)) { throw new StorageException('Signal state base64 field is malformed.'); }
        $decoded = base64_decode($value, true);
        if (!is_string($decoded) || strlen($decoded) !== $length || base64_encode($decoded) !== $value) { throw new StorageException('Signal state base64 field is malformed.'); }
        return $decoded;
    }
    private function integer($value, int $minimum = 0): int { if (!is_int($value) || $value < $minimum || $value > 0xffffffff) { throw new StorageException('Signal state counter is malformed.'); } return $value; }
    private function isList(array $value): bool { $i = 0; foreach ($value as $key => $_) { if ($key !== $i++) { return false; } } return true; }
}
