<?php

declare(strict_types=1);

namespace PhpWA\Crypto;

use InvalidArgumentException;
use PhpWA\Exception\CryptoException;

/**
 * Isolates the small set of sodium_compat internal Edwards operations needed
 * for Signal-compatible XEdDSA. No other package class may call those methods.
 */
final class SodiumCompatXEdDsa implements XEdDsaInterface
{
    private const HASH_PREFIX_HEX = 'feffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
    private const GROUP_ORDER_HEX = 'edd3f55c1a631258d69cf7a2def9de1400000000000000000000000000000010';
    private const GROUP_ORDER_MINUS_ONE_HEX = 'ecd3f55c1a631258d69cf7a2def9de1400000000000000000000000000000010';

    /** @var RandomSourceInterface */ private $random;

    public function __construct(?RandomSourceInterface $random = null)
    {
        $this->random = $random ?? new SystemRandomSource();
    }

    public function sign(string $curve25519PrivateKey, string $message, ?string $nonce = null): string
    {
        if (strlen($curve25519PrivateKey) !== 32) {
            throw new InvalidArgumentException('XEdDSA private key must contain exactly 32 bytes.');
        }
        $nonce = $nonce ?? $this->random->bytes(64);
        if (strlen($nonce) !== 64) {
            throw new InvalidArgumentException('XEdDSA nonce must contain exactly 64 bytes.');
        }
        try {
            $scalar = $this->reduceScalar($curve25519PrivateKey);
            $public = $this->edwardsPublic($scalar);
            $signBit = ord($public[31]) >> 7;
            $public[31] = chr(ord($public[31]) & 127);
            if ($signBit !== 0) {
                $scalar = \ParagonIE_Sodium_Core_Curve25519::sc_muladd(
                    self::hex(self::GROUP_ORDER_MINUS_ONE_HEX),
                    $scalar,
                    str_repeat("\0", 32)
                );
            }
            $r = $this->reduceScalar(CryptoPrimitives::sha512(self::hex(self::HASH_PREFIX_HEX) . $scalar . $message . $nonce));
            $encodedR = $this->edwardsPublic($r);
            $h = $this->reduceScalar(CryptoPrimitives::sha512($encodedR . $public . $message));
            $s = \ParagonIE_Sodium_Core_Curve25519::sc_muladd($h, $scalar, $r);
            if (strlen($s) !== 32) {
                throw new CryptoException('XEdDSA scalar operation returned invalid output.');
            }
            return $encodedR . $s;
        } catch (CryptoException $error) {
            throw $error;
        } catch (\Throwable $error) {
            throw new CryptoException('XEdDSA signing failed.', 0, $error);
        }
    }

    public function verify(string $curve25519PublicKey, string $message, string $signature): bool
    {
        if (strlen($curve25519PublicKey) !== 32 || strlen($signature) !== 64) {
            return false;
        }
        $encodedR = substr($signature, 0, 32);
        $s = substr($signature, 32, 32);
        if (!$this->isCanonicalScalar($s)) {
            return false;
        }
        try {
            // Parse R only to reject malformed/non-curve encodings before the
            // double-scalar comparison.
            \ParagonIE_Sodium_Core_Curve25519::ge_frombytes_negate_vartime($encodedR);
            $public = $this->montgomeryToEdwards($curve25519PublicKey);
            $h = $this->reduceScalar(CryptoPrimitives::sha512($encodedR . $public . $message));
            $negativePublic = \ParagonIE_Sodium_Core_Curve25519::ge_frombytes_negate_vartime($public);
            $checkPoint = \ParagonIE_Sodium_Core_Curve25519::ge_double_scalarmult_vartime($h, $negativePublic, $s);
            $check = \ParagonIE_Sodium_Core_Curve25519::ge_tobytes($checkPoint);
            return is_string($check) && hash_equals($encodedR, $check);
        } catch (\Throwable $error) {
            return false;
        }
    }

    private function reduceScalar(string $value): string
    {
        if (strlen($value) < 64) {
            $value .= str_repeat("\0", 64 - strlen($value));
        }
        $reduced = \ParagonIE_Sodium_Core_Curve25519::sc_reduce(substr($value, 0, 64));
        if (!is_string($reduced) || strlen($reduced) !== 32) {
            throw new CryptoException('XEdDSA scalar reduction failed.');
        }
        return $reduced;
    }

    private function edwardsPublic(string $scalar): string
    {
        $point = \ParagonIE_Sodium_Core_Curve25519::ge_scalarmult_base($scalar);
        $encoded = \ParagonIE_Sodium_Core_Curve25519::ge_p3_tobytes($point);
        if (!is_string($encoded) || strlen($encoded) !== 32) {
            throw new CryptoException('XEdDSA Edwards point encoding failed.');
        }
        return $encoded;
    }

    private function montgomeryToEdwards(string $publicKey): string
    {
        $field = \ParagonIE_Sodium_Core_Curve25519::fe_frombytes($publicKey);
        $one = \ParagonIE_Sodium_Core_Curve25519::fe_1();
        $numerator = \ParagonIE_Sodium_Core_Curve25519::fe_sub($field, $one);
        $denominator = \ParagonIE_Sodium_Core_Curve25519::fe_add($field, $one);
        if (\ParagonIE_Sodium_Core_Curve25519::fe_isnonzero($denominator) === 0) {
            throw new CryptoException('Invalid Montgomery public key.');
        }
        $y = \ParagonIE_Sodium_Core_Curve25519::fe_mul(
            $numerator,
            \ParagonIE_Sodium_Core_Curve25519::fe_invert($denominator)
        );
        $encoded = \ParagonIE_Sodium_Core_Curve25519::fe_tobytes($y);
        $encoded[31] = chr(ord($encoded[31]) & 127);
        return $encoded;
    }

    private function isCanonicalScalar(string $scalar): bool
    {
        $order = self::hex(self::GROUP_ORDER_HEX);
        for ($index = 31; $index >= 0; $index--) {
            $left = ord($scalar[$index]);
            $right = ord($order[$index]);
            if ($left < $right) { return true; }
            if ($left > $right) { return false; }
        }
        return false;
    }

    private static function hex(string $hex): string
    {
        $decoded = hex2bin($hex);
        if (!is_string($decoded)) {
            throw new CryptoException('Invalid internal hexadecimal constant.');
        }
        return $decoded;
    }
}
