<?php

declare(strict_types=1);

namespace PhpWA\Transport\Noise;

use InvalidArgumentException;
use PhpWA\Crypto\KeyPair;
use PhpWA\Crypto\RandomSourceInterface;
use PhpWA\Crypto\SystemRandomSource;
use PhpWA\Crypto\X25519;
use PhpWA\Exception\ConnectionClosedException;
use PhpWA\Exception\ProtocolException;
use PhpWA\Exception\TimeoutException;
use PhpWA\Runtime\Deadline;
use PhpWA\Protocol\Proto\HandshakeMessage;
use PhpWA\Protocol\Proto\HandshakeMessage\ClientFinish;
use PhpWA\Protocol\Proto\HandshakeMessage\ClientHello;
use PhpWA\Protocol\ProtobufCodec;
use PhpWA\Transport\WebSocket\WebSocketInterface;

final class NoiseSocket implements NoiseTransportInterface
{
    private const STATE_NEW = 'new';
    private const STATE_OPEN = 'open';
    private const STATE_CLOSED = 'closed';
    private const STATE_FAILED = 'failed';
    private const MAX_HANDSHAKE_BYTES = 1048576;

    /** @var WebSocketInterface */ private $webSocket;
    /** @var RandomSourceInterface */ private $random;
    /** @var ProtobufCodec */ private $protobuf;
    /** @var NoiseHandler|null */ private $noise;
    /** @var list<string> */ private $pendingPayloads = [];
    /** @var string */ private $state = self::STATE_NEW;

    public function __construct(
        WebSocketInterface $webSocket,
        ?RandomSourceInterface $random = null,
        ?ProtobufCodec $protobuf = null
    ) {
        $this->webSocket = $webSocket;
        $this->random = $random ?? new SystemRandomSource();
        $this->protobuf = $protobuf ?? new ProtobufCodec(self::MAX_HANDSHAKE_BYTES);
        $this->noise = null;
    }

    /**
     * @param callable(string,string):bool $verifyCertificate
     */
    public function open(
        KeyPair $staticNoiseKey,
        ?string $routingInfo,
        string $clientPayload,
        callable $verifyCertificate,
        Deadline $deadline
    ): void {
        if ($this->state !== self::STATE_NEW) {
            throw new ProtocolException('Noise socket instances may be opened only once.');
        }
        if ($clientPayload === '' || strlen($clientPayload) > self::MAX_HANDSHAKE_BYTES) {
            throw new InvalidArgumentException('Noise client payload is empty or too large.');
        }
        try {
            $ephemeral = X25519::generate($this->random);
            $this->noise = new NoiseHandler($ephemeral, $routingInfo);
            $this->webSocket->connect($deadline);

            $clientHello = new HandshakeMessage([
                'clientHello' => new ClientHello(['ephemeral' => $ephemeral->publicKey()]),
            ]);
            $this->webSocket->sendBinary(
                $this->noise->encodeFrame($this->protobuf->encode($clientHello)),
                $deadline
            );
            $serverFrame = $this->receiveHandshakeFrame($deadline);
            /** @var HandshakeMessage $serverMessage */
            $serverMessage = $this->protobuf->decode(HandshakeMessage::class, $serverFrame);
            $serverHello = $serverMessage->getServerHello();
            if ($serverHello === null || strlen($serverHello->getEphemeral()) !== 32
                || strlen($serverHello->getStatic()) < 16 || strlen($serverHello->getPayload()) < 16) {
                throw new ProtocolException('WhatsApp Noise ServerHello is incomplete.');
            }
            $encryptedStatic = $this->noise->processServerHello(
                new ServerHello($serverHello->getEphemeral(), $serverHello->getStatic(), $serverHello->getPayload()),
                $staticNoiseKey,
                $verifyCertificate
            );
            $encryptedPayload = $this->noise->encryptHandshake($clientPayload);
            $clientFinish = new HandshakeMessage([
                'clientFinish' => new ClientFinish([
                    'static' => $encryptedStatic,
                    'payload' => $encryptedPayload,
                ]),
            ]);
            $this->webSocket->sendBinary(
                $this->noise->encodeFrame($this->protobuf->encode($clientFinish)),
                $deadline
            );
            $this->noise->finishHandshake();
            $this->state = self::STATE_OPEN;
        } catch (\Throwable $error) {
            $this->state = self::STATE_FAILED;
            $this->pendingPayloads = [];
            try { $this->webSocket->close(1000, ''); } catch (\Throwable $ignored) {}
            throw $error;
        }
    }

    public function sendPayload(string $payload, Deadline $deadline): void
    {
        $noise = $this->requireOpenNoise();
        $this->webSocket->sendBinary($noise->encodeFrame($payload), $deadline);
    }

    public function receivePayload(Deadline $deadline): string
    {
        $noise = $this->requireOpenNoise();
        while ($this->pendingPayloads === []) {
            try {
                $wire = $this->webSocket->receiveBinary($deadline);
                foreach ($noise->decodeFrames($wire) as $payload) {
                    $this->pendingPayloads[] = $payload;
                }
            } catch (TimeoutException $error) {
                // Idle read slices let the outer worker refresh its generation
                // fence and heartbeat without destroying a live Noise session.
                throw $error;
            } catch (\Throwable $error) {
                $this->state = self::STATE_FAILED;
                $this->pendingPayloads = [];
                throw $error;
            }
        }
        return array_shift($this->pendingPayloads);
    }

    public function close(): void
    {
        $this->pendingPayloads = [];
        if ($this->state !== self::STATE_CLOSED) {
            try { $this->webSocket->close(1000, ''); } finally { $this->state = self::STATE_CLOSED; }
        }
    }

    public function isOpen(): bool
    {
        return $this->state === self::STATE_OPEN && $this->webSocket->isOpen();
    }

    private function receiveHandshakeFrame(Deadline $deadline): string
    {
        $noise = $this->noise;
        if (!$noise instanceof NoiseHandler) {
            throw new ProtocolException('Noise handshake state is unavailable.');
        }
        while (true) {
            $frames = $noise->decodeFrames($this->webSocket->receiveBinary($deadline));
            if (count($frames) > 1) {
                throw new ProtocolException('Unexpected extra frames before Noise transport initialization.');
            }
            if (isset($frames[0])) { return $frames[0]; }
        }
    }

    private function requireOpenNoise(): NoiseHandler
    {
        if (!$this->isOpen() || !$this->noise instanceof NoiseHandler) {
            throw new ConnectionClosedException('Noise transport is not open.');
        }
        return $this->noise;
    }
}
