<?php

declare(strict_types=1);

namespace PhpWA\Auth;

use InvalidArgumentException;
use PhpWA\Crypto\SodiumCompatXEdDsa;
use PhpWA\Crypto\XEdDsaInterface;
use PhpWA\Protocol\Proto\CertChain;
use PhpWA\Protocol\Proto\CertChain\NoiseCertificate\Details;
use PhpWA\Protocol\ProtobufCodec;

final class NoiseCertificateVerifier
{
    private const ROOT_PUBLIC_KEY_HEX = '142375574d0a587166aae71ebe516437c4a28b73e3695c6ce1f7f9545da8ee6b';
    private const ROOT_ISSUER_SERIAL = 0;
    private const MAX_CHAIN_BYTES = 65536;
    private const MAX_DETAILS_BYTES = 8192;

    /** @var string */ private $rootPublicKey;
    /** @var XEdDsaInterface */ private $xeddsa;
    /** @var callable */ private $clock;
    /** @var ProtobufCodec */ private $protobuf;

    public function __construct(
        ?string $rootPublicKey = null,
        ?XEdDsaInterface $xeddsa = null,
        ?callable $clock = null
    ) {
        $rootPublicKey = $rootPublicKey ?? hex2bin(self::ROOT_PUBLIC_KEY_HEX);
        if (!is_string($rootPublicKey) || strlen($rootPublicKey) !== 32) {
            throw new InvalidArgumentException('WhatsApp Noise root public key must contain 32 bytes.');
        }
        $this->rootPublicKey = $rootPublicKey;
        $this->xeddsa = $xeddsa ?? new SodiumCompatXEdDsa();
        $this->clock = $clock ?? static function (): int { return time(); };
        $this->protobuf = new ProtobufCodec(self::MAX_CHAIN_BYTES);
    }

    public function verify(string $chainBytes, string $serverStaticKey): bool
    {
        if ($chainBytes === '' || strlen($chainBytes) > self::MAX_CHAIN_BYTES || strlen($serverStaticKey) !== 32) {
            return false;
        }
        try {
            /** @var CertChain $chain */
            $chain = $this->protobuf->decode(CertChain::class, $chainBytes);
            $intermediate = $chain->getIntermediate();
            $leaf = $chain->getLeaf();
            if ($intermediate === null || $leaf === null) { return false; }
            $intermediateRaw = $intermediate->getDetails();
            $leafRaw = $leaf->getDetails();
            if ($intermediateRaw === '' || $leafRaw === ''
                || strlen($intermediateRaw) > self::MAX_DETAILS_BYTES || strlen($leafRaw) > self::MAX_DETAILS_BYTES
                || strlen($intermediate->getSignature()) !== 64 || strlen($leaf->getSignature()) !== 64) {
                return false;
            }
            if (!$this->xeddsa->verify($this->rootPublicKey, $intermediateRaw, $intermediate->getSignature())) {
                return false;
            }
            /** @var Details $intermediateDetails */
            $intermediateDetails = $this->protobuf->decode(Details::class, $intermediateRaw);
            if ($intermediateDetails->getIssuerSerial() !== self::ROOT_ISSUER_SERIAL
                || strlen($intermediateDetails->getKey()) !== 32
                || !$this->validAt($intermediateDetails, ($this->clock)())) {
                return false;
            }
            if (!$this->xeddsa->verify($intermediateDetails->getKey(), $leafRaw, $leaf->getSignature())) {
                return false;
            }
            /** @var Details $leafDetails */
            $leafDetails = $this->protobuf->decode(Details::class, $leafRaw);
            if ($leafDetails->getIssuerSerial() !== $intermediateDetails->getSerial()
                || strlen($leafDetails->getKey()) !== 32
                || !hash_equals($serverStaticKey, $leafDetails->getKey())
                || !$this->validAt($leafDetails, ($this->clock)())) {
                return false;
            }
            return true;
        } catch (\Throwable $error) {
            return false;
        }
    }

    public function __invoke(string $chainBytes, string $serverStaticKey): bool
    {
        return $this->verify($chainBytes, $serverStaticKey);
    }

    private function validAt(Details $details, int $now): bool
    {
        $notBefore = $this->uint64ToInt($details->getNotBefore());
        $notAfter = $this->uint64ToInt($details->getNotAfter());
        return $notBefore !== null && $notAfter !== null && $notBefore <= $now && $now <= $notAfter;
    }

    /** @param int|string $value */
    private function uint64ToInt($value): ?int
    {
        if (is_int($value)) { return $value >= 0 ? $value : null; }
        if (!is_string($value) || preg_match('/^(0|[1-9][0-9]{0,18})$/D', $value) !== 1) { return null; }
        if (strlen($value) > strlen((string) PHP_INT_MAX)
            || (strlen($value) === strlen((string) PHP_INT_MAX) && strcmp($value, (string) PHP_INT_MAX) > 0)) {
            return null;
        }
        return (int) $value;
    }
}
