<?php

declare(strict_types=1);

namespace Logliy\Webauthn\AttestationStatement;

use function array_key_exists;
use Logliy\CBOR\Decoder;
use Logliy\CBOR\MapObject;
use Logliy\Cose\Key\Ec2Key;
use function count;
use function is_array;
use const OPENSSL_ALGO_SHA256;
use function openssl_pkey_get_public;
use function openssl_verify;
use OpenSSLAsymmetricKey;
use Logliy\Psr\EventDispatcher\EventDispatcherInterface;
use function sprintf;
use Throwable;
use Logliy\Webauthn\AuthenticatorData;
use Logliy\Webauthn\Event\AttestationStatementLoaded;
use Logliy\Webauthn\Event\CanDispatchEvents;
use Logliy\Webauthn\Event\NullEventDispatcher;
use Logliy\Webauthn\Exception\AttestationStatementLoadingException;
use Logliy\Webauthn\Exception\AttestationStatementVerificationException;
use Logliy\Webauthn\Exception\InvalidAttestationStatementException;
use Logliy\Webauthn\MetadataService\CertificateChain\CertificateToolbox;
use Logliy\Webauthn\StringStream;
use Logliy\Webauthn\TrustPath\CertificateTrustPath;

final class FidoU2FAttestationStatementSupport implements AttestationStatementSupport, CanDispatchEvents
{
    private readonly Decoder $decoder;

    private EventDispatcherInterface $dispatcher;

    public function __construct()
    {
        $this->decoder = Decoder::create();
        $this->dispatcher = new NullEventDispatcher();
    }

    public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
    {
        $this->dispatcher = $eventDispatcher;
    }

    public static function create(): self
    {
        return new self();
    }

    public function name(): string
    {
        return 'fido-u2f';
    }

    /**
     * @param array<string, mixed> $attestation
     */
    public function load(array $attestation): AttestationStatement
    {
        array_key_exists('attStmt', $attestation) || throw AttestationStatementLoadingException::create(
            $attestation,
            'Invalid attestation object'
        );
        /** @var array<string, mixed> $attStmt */
        $attStmt = $attestation['attStmt'];
        foreach (['sig', 'x5c'] as $key) {
            array_key_exists($key, $attStmt) || throw AttestationStatementLoadingException::create(
                $attestation,
                sprintf('The attestation statement value "%s" is missing.', $key)
            );
        }
        /** @var array<string> $certificates */
        $certificates = $attStmt['x5c'];
        is_array($certificates) || throw AttestationStatementLoadingException::create(
            $attestation,
            'The attestation statement value "x5c" must be a list with one certificate.'
        );
        count($certificates) === 1 || throw AttestationStatementLoadingException::create(
            $attestation,
            'The attestation statement value "x5c" must be a list with one certificate.'
        );

        reset($certificates);
        $certificates = CertificateToolbox::convertAllDERToPEM($certificates);
        $this->checkCertificate($certificates[0]);

        /** @var string $fmt */
        $fmt = $attestation['fmt'];
        $attestationStatement = AttestationStatement::createBasic(
            $fmt,
            $attStmt,
            CertificateTrustPath::create($certificates)
        );
        $this->dispatcher->dispatch(AttestationStatementLoaded::create($attestationStatement));

        return $attestationStatement;
    }

    public function isValid(
        string $clientDataJSONHash,
        AttestationStatement $attestationStatement,
        AuthenticatorData $authenticatorData
    ): bool {
        $authenticatorData->attestedCredentialData
            ?->aaguid
            ->__toString() === '00000000-0000-0000-0000-000000000000' || throw InvalidAttestationStatementException::create(
                $attestationStatement,
                'Invalid AAGUID for fido-u2f attestation statement. Shall be "00000000-0000-0000-0000-000000000000"'
            );
        $trustPath = $attestationStatement->trustPath;
        $trustPath instanceof CertificateTrustPath || throw InvalidAttestationStatementException::create(
            $attestationStatement,
            'Invalid trust path'
        );
        $dataToVerify = "\0";
        $dataToVerify .= $authenticatorData->rpIdHash;
        $dataToVerify .= $clientDataJSONHash;
        $dataToVerify .= $authenticatorData->attestedCredentialData
            ->credentialId;
        $dataToVerify .= $this->extractPublicKey($authenticatorData->attestedCredentialData ->credentialPublicKey);

        /** @var string $sig */
        $sig = $attestationStatement->get('sig');
        return openssl_verify($dataToVerify, $sig, $trustPath->certificates[0], OPENSSL_ALGO_SHA256) === 1;
    }

    private function extractPublicKey(?string $publicKey): string
    {
        $publicKey !== null || throw AttestationStatementVerificationException::create(
            'The attested credential data does not contain a valid public key.'
        );

        $publicKeyStream = new StringStream($publicKey);
        $coseKey = $this->decoder->decode($publicKeyStream);
        $publicKeyStream->isEOF() || throw AttestationStatementVerificationException::create(
            'Invalid public key. Presence of extra bytes.'
        );
        $publicKeyStream->close();
        $coseKey instanceof MapObject || throw AttestationStatementVerificationException::create(
            'The attested credential data does not contain a valid public key.'
        );

        $coseKey = $coseKey->normalize();
        $ec2Key = new Ec2Key($coseKey + [
            Ec2Key::TYPE => 2,
            Ec2Key::DATA_CURVE => Ec2Key::CURVE_P256,
        ]);

        return "\x04" . $ec2Key->x() . $ec2Key->y();
    }

    private function checkCertificate(string $publicKey): void
    {
        try {
            $resource = openssl_pkey_get_public($publicKey);
            $resource !== false || throw AttestationStatementVerificationException::create(
                'Invalid certificate or certificate chain'
            );
            /** @var OpenSSLAsymmetricKey $resource */
            $details = openssl_pkey_get_details($resource);
        } catch (Throwable $throwable) {
            throw AttestationStatementVerificationException::create(
                'Invalid certificate or certificate chain',
                $throwable
            );
        }
        is_array($details) || throw AttestationStatementVerificationException::create(
            'Invalid certificate or certificate chain'
        );
        array_key_exists('ec', $details) || throw AttestationStatementVerificationException::create(
            'Invalid certificate or certificate chain'
        );
        /** @var array<string, mixed> $ec */
        $ec = $details['ec'];
        array_key_exists('curve_name', $ec) || throw AttestationStatementVerificationException::create(
            'Invalid certificate or certificate chain'
        );
        $ec['curve_name'] === 'prime256v1' || throw AttestationStatementVerificationException::create(
            'Invalid certificate or certificate chain'
        );
        array_key_exists('curve_oid', $ec) || throw AttestationStatementVerificationException::create(
            'Invalid certificate or certificate chain'
        );
        $ec['curve_oid'] === '1.2.840.10045.3.1.7' || throw AttestationStatementVerificationException::create(
            'Invalid certificate or certificate chain'
        );
    }
}
