<?php

declare(strict_types=1);

namespace PhpWA\Transport\Noise;

use InvalidArgumentException;
use PhpWA\Crypto\CryptoPrimitives;
use PhpWA\Crypto\KeyPair;
use PhpWA\Crypto\X25519;
use PhpWA\Exception\CryptoException;
use PhpWA\Exception\ProtocolException;
use PhpWA\Transport\FrameCodec;
use PhpWA\Transport\FrameDecoder;

final class NoiseHandler
{
    public const MODE = "Noise_XX_25519_AESGCM_SHA256\0\0\0\0";
    public const DICTIONARY_VERSION = 3;
    private const STATE_HANDSHAKE = 'handshake';
    private const STATE_CLIENT_FINISH = 'client_finish';
    private const STATE_TRANSPORT = 'transport';
    private const STATE_FAILED = 'failed';

    /** @var KeyPair */ private $ephemeralKeyPair;
    /** @var string */ private $header;
    /** @var string */ private $introHeader;
    /** @var string */ private $hash;
    /** @var string */ private $salt;
    /** @var string */ private $encryptKey;
    /** @var string */ private $decryptKey;
    /** @var int */ private $counter = 0;
    /** @var bool */ private $sentIntro = false;
    /** @var string */ private $state = self::STATE_HANDSHAKE;
    /** @var NoiseTransportState|null */ private $transport;
    /** @var FrameDecoder */ private $decoder;
    /** @var int */ private $maxFrameLength;
    /** @var bool */ private $clientFinishPayloadEncrypted = false;

    public function __construct(
        KeyPair $ephemeralKeyPair,
        ?string $routingInfo = null,
        ?string $header = null,
        int $maxFrameLength = FrameCodec::MAX_WIRE_LENGTH
    ) {
        $this->ephemeralKeyPair = $ephemeralKeyPair;
        $this->header = $header ?? FrameCodec::routingHeader(6, self::DICTIONARY_VERSION);
        if (strlen($this->header) < 1 || strlen($this->header) > 255) {
            throw new InvalidArgumentException('Noise routing header length is invalid.');
        }
        if ($routingInfo !== null && strlen($routingInfo) > FrameCodec::MAX_WIRE_LENGTH) {
            throw new InvalidArgumentException('Noise routing info exceeds the 3-byte length limit.');
        }
        if ($routingInfo !== null && $routingInfo !== '') {
            $length = strlen($routingInfo);
            $this->introHeader = 'ED' . "\0\x01" . chr(($length >> 16) & 255) . chr(($length >> 8) & 255) . chr($length & 255) . $routingInfo . $this->header;
        } else {
            $this->introHeader = $this->header;
        }
        $initial = strlen(self::MODE) === 32 ? self::MODE : CryptoPrimitives::sha256(self::MODE);
        $this->hash = $initial;
        $this->salt = $initial;
        $this->encryptKey = $initial;
        $this->decryptKey = $initial;
        $this->maxFrameLength = $maxFrameLength;
        $this->decoder = new FrameDecoder($maxFrameLength, max($maxFrameLength + 3, min(PHP_INT_MAX, ($maxFrameLength + 3) * 2)));
        $this->authenticate($this->header);
        $this->authenticate($ephemeralKeyPair->publicKey());
    }

    public static function iv(int $counter): string
    {
        if ($counter < 0 || $counter > 4294967295) {
            throw new CryptoException('Noise nonce counter is out of range.');
        }
        return str_repeat("\0", 8) . pack('N', $counter);
    }

    public function authenticate(string $data): void
    {
        $this->requireNotFailed();
        if ($this->state !== self::STATE_TRANSPORT) {
            $this->hash = CryptoPrimitives::sha256($this->hash . $data);
        }
    }

    public function mixIntoKey(string $input): void
    {
        $this->requireHandshakeState();
        [$write, $read] = $this->localHkdf($input);
        $this->salt = $write;
        $this->encryptKey = $read;
        $this->decryptKey = $read;
        $this->counter = 0;
    }

    public function encryptHandshake(string $plaintext): string
    {
        $this->requireHandshakeState();
        $this->requireCounter();
        try {
            $ciphertext = CryptoPrimitives::aesGcmEncrypt($this->encryptKey, $plaintext, self::iv($this->counter), $this->hash);
            $this->counter++;
            $this->authenticate($ciphertext);
            if ($this->state === self::STATE_CLIENT_FINISH) {
                $this->clientFinishPayloadEncrypted = true;
            }
            return $ciphertext;
        } catch (\Throwable $error) {
            $this->state = self::STATE_FAILED;
            throw $error;
        }
    }

    public function decryptHandshake(string $ciphertext): string
    {
        $this->requireHandshakeState();
        $this->requireCounter();
        try {
            $plaintext = CryptoPrimitives::aesGcmDecrypt($this->decryptKey, $ciphertext, self::iv($this->counter), $this->hash);
            $this->counter++;
            $this->authenticate($ciphertext);
            return $plaintext;
        } catch (\Throwable $error) {
            $this->state = self::STATE_FAILED;
            throw $error;
        }
    }

    /**
     * @param callable(string,string):bool $verifyCertificate
     */
    public function processServerHello(ServerHello $hello, KeyPair $staticNoiseKey, callable $verifyCertificate): string
    {
        if ($this->state !== self::STATE_HANDSHAKE) {
            throw new CryptoException('Noise server hello is not valid in the current state.');
        }
        try {
            $this->authenticate($hello->ephemeral());
            $this->mixIntoKey(X25519::sharedSecret($this->ephemeralKeyPair->privateKey(), $hello->ephemeral()));
            $serverStatic = $this->decryptHandshake($hello->staticCiphertext());
            if (strlen($serverStatic) !== 32) {
                throw new CryptoException('Noise server static key has invalid length.');
            }
            $this->mixIntoKey(X25519::sharedSecret($this->ephemeralKeyPair->privateKey(), $serverStatic));
            $certificate = $this->decryptHandshake($hello->payloadCiphertext());
            if ($verifyCertificate($certificate, $serverStatic) !== true) {
                throw new CryptoException('Noise server certificate verification failed.');
            }
            $encryptedStatic = $this->encryptHandshake($staticNoiseKey->publicKey());
            $this->mixIntoKey(X25519::sharedSecret($staticNoiseKey->privateKey(), $hello->ephemeral()));
            $this->state = self::STATE_CLIENT_FINISH;
            return $encryptedStatic;
        } catch (\Throwable $error) {
            $this->state = self::STATE_FAILED;
            if ($error instanceof CryptoException) { throw $error; }
            throw new CryptoException('Noise server hello processing failed.', 0, $error);
        }
    }

    public function finishHandshake(): void
    {
        if ($this->state !== self::STATE_CLIENT_FINISH) {
            throw new CryptoException('Noise handshake cannot finish in the current state.');
        }
        if (!$this->clientFinishPayloadEncrypted) {
            $this->state = self::STATE_FAILED;
            throw new CryptoException('Noise client-finish payload must be encrypted before transport starts.');
        }
        [$write, $read] = $this->localHkdf('');
        $this->transport = new NoiseTransportState($write, $read);
        $this->state = self::STATE_TRANSPORT;
    }

    public function encodeFrame(string $payload): string
    {
        $this->requireNotFailed();
        if ($this->state === self::STATE_TRANSPORT) {
            $payload = $this->transportState()->encrypt($payload);
        }
        if (strlen($payload) > $this->maxFrameLength) {
            throw new ProtocolException('Noise frame exceeds configured limit.');
        }
        $frame = FrameCodec::encode($payload, false, $this->maxFrameLength);
        if (!$this->sentIntro) {
            $frame = $this->introHeader . $frame;
            $this->sentIntro = true;
        }
        return $frame;
    }

    /** @return list<string> */
    public function decodeFrames(string $bytes): array
    {
        $this->requireNotFailed();
        $frames = $this->decoder->feed($bytes);
        if ($this->state !== self::STATE_TRANSPORT) {
            return $frames;
        }
        $plaintext = [];
        try {
            foreach ($frames as $frame) {
                $plaintext[] = $this->transportState()->decrypt($frame);
            }
        } catch (\Throwable $error) {
            $this->state = self::STATE_FAILED;
            throw $error;
        }
        return $plaintext;
    }

    public function isTransportReady(): bool { return $this->state === self::STATE_TRANSPORT; }
    public function isFailed(): bool { return $this->state === self::STATE_FAILED; }
    public function pendingFrameBytes(): int { return $this->decoder->pendingBytes(); }
    public function publicEphemeralKey(): string { return $this->ephemeralKeyPair->publicKey(); }

    private function localHkdf(string $input): array
    {
        $output = CryptoPrimitives::hkdf($input, 64, $this->salt, '');
        return [substr($output, 0, 32), substr($output, 32, 32)];
    }

    private function transportState(): NoiseTransportState
    {
        if (!$this->transport instanceof NoiseTransportState) {
            throw new CryptoException('Noise transport state is not available.');
        }
        return $this->transport;
    }

    private function requireHandshakeState(): void
    {
        $this->requireNotFailed();
        if ($this->state === self::STATE_TRANSPORT) {
            throw new CryptoException('Noise handshake operation used after transport initialization.');
        }
    }

    private function requireNotFailed(): void
    {
        if ($this->state === self::STATE_FAILED) {
            throw new CryptoException('Noise state is unusable after a cryptographic failure.');
        }
    }

    private function requireCounter(): void
    {
        if ($this->counter > 4294967295) {
            $this->state = self::STATE_FAILED;
            throw new CryptoException('Noise handshake nonce counter exhausted.');
        }
    }
}
