<?php

namespace classes;

use Exception;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Uid\Uuid;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\TrustPath\EmptyTrustPath;
use objects\passkeys_o;
use CBOR\Encoder;

class webauthn
{
    private AuthenticatorAssertionResponseValidator $validator;
    private SerializerInterface $serializer;

    public function __construct()
    {
        $factory = new CeremonyStepManagerFactory();
        
        // In a real scenario, we should set allowed origins.
        // We use the $_SERVER['HTTP_ORIGIN'] or similar.
        $rpId = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
        $factory->setAllowedOrigins(['https://' . $rpId, 'http://' . $rpId]);

        $this->validator = new AuthenticatorAssertionResponseValidator(
            $factory->requestCeremony()
        );

        $attestationStatementSupportManager = new AttestationStatementSupportManager();
        
        $serializerFactory = new WebauthnSerializerFactory($attestationStatementSupportManager);
        $this->serializer = $serializerFactory->create();
    }

    /**
     * Verify a WebAuthn assertion
     * 
     * @param string $assertionJson The JSON string from the client
     * @param string $challengeToken The hex challenge token stored in our DB
     * @param passkeys_o $passkey The passkey object from our DB
     * @param string $host The host (rpId) to verify against
     * @return bool
     */
    public function verifyAssertion(
        string $assertionJson,
        string $challengeToken,
        passkeys_o $passkey,
        string $host
    ): bool {
        try {
            $publicKeyCredential = $this->serializer->deserialize($assertionJson, PublicKeyCredential::class, 'json');
            $response = $publicKeyCredential->response;

            if (!$response instanceof AuthenticatorAssertionResponse) {
                throw new Exception('Not an assertion response');
            }

            // Create PublicKeyCredentialSource from passkey object
            $source = $this->createSourceFromObject($passkey);

            // Prepare challenge (binary as expected by the WebAuthn lib)
            $challenge = hex2bin($challengeToken);

            // Create PublicKeyCredentialRequestOptions
            $options = PublicKeyCredentialRequestOptions::create(
                $challenge,
                $host
            );

            // Check
            $this->validator->check(
                $source,
                $response,
                $options,
                $host,
                $source->userHandle
            );

            // If it didn't throw, it's valid. Update sign count.
            // Note: validator updates source counter in v5
            $passkey->sign_count->set($source->counter);

            return true;
        } catch (Exception $e) {
            // Log error
            $pk_hex = bin2hex($this->b64urlDecode((string)$passkey->public_key->value()));
            error_log("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)");
            throw new Exception("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)");
            return false;
        } catch (ExceptionInterface $e) {
            throw new Exception('Serialization error: ' . $e->getMessage());
        }
    }

    private function createSourceFromObject(passkeys_o $passkey): PublicKeyCredentialSource
    {
        $transports = $passkey->transports->value() ?: [];

        $credential_id_bin = $this->b64urlDecode((string)$passkey->credential_id->value());
        $public_key_raw = $this->b64urlDecode((string)$passkey->public_key->value());

        // Ensure the credential public key is in COSE (CBOR) format as expected by webauthn-lib
        $credentialPublicKey = $this->ensureCosePublicKey($public_key_raw, (string)$passkey->algorithm->value());

        return new PublicKeyCredentialSource(
            $credential_id_bin, // publicKeyCredentialId (binary)
            'public-key',
            $transports,
            'none', // attestationType
            new EmptyTrustPath(),
            Uuid::fromString('00000000-0000-0000-0000-000000000000'), // aaguid
            $credentialPublicKey, // credentialPublicKey (COSE/CBOR binary)
            (string)$passkey->user_id->value(), // userHandle
            (int)$passkey->sign_count->value()
        );
    }

    /**
     * Base64URL decode with padding handling
     */
    public function b64urlDecode(string $data): string
    {
        $b64 = strtr($data, '-_', '+/');
        $pad = strlen($b64) % 4;
        if ($pad) {
            $b64 .= str_repeat('=', 4 - $pad);
        }
        $decoded = base64_decode($b64, true);
        if ($decoded === false) {
            // As a last resort, try non-strict decode
            $decoded = base64_decode($b64, false);
        }
        return $decoded === false ? '' : $decoded;
    }

    /**
     * Detects if the given data looks like a PEM public key (ASCII) or DER SPKI (ASN.1 sequence)
     */
    private function looksLikePemOrDer(string $data): bool
    {
        if ($data === '') {
            return false;
        }
        if (str_contains($data, '-----BEGIN')) {
            return true;
        }
        // DER SEQUENCE usually starts with 0x30
        return isset($data[0]) && ord($data[0]) === 0x30;
    }

    /**
     * Convert PEM/DER EC public key to COSE EC2 key (CBOR binary). If already COSE, return as-is.
     * Supports ES256/P-256. Other curves will throw.
     */
    public function ensureCosePublicKey(string $data, string $algorithm): string
    {
        // If data already looks like COSE (CBOR map) with small leading major type 5 (map)
        // we check if it is indeed a valid CBOR map with at least the 'kty' (1) key.
        if ($data !== '' && (ord($data[0]) >= 0xA0 && ord($data[0]) <= 0xBF)) {
            try {
                $stream = new \Webauthn\StringStream($data);
                $object = \CBOR\Decoder::create()->decode($stream);
                if ($object instanceof \CBOR\Normalizable) {
                    $normalized = $object->normalize();
                    if (is_array($normalized)) {
                        if (isset($normalized[1])) {
                            return $data; // valid COSE map
                        }

                        // Some clients send the full attestation object instead of just the public key.
                        // We detect this by checking for 'authData' and 'fmt' keys in the CBOR map.
                        if (isset($normalized['authData']) && isset($normalized['fmt']) && is_string($normalized['authData'])) {
                            $authData = $normalized['authData'];
                            if (strlen($authData) >= 37) {
                                $flags = ord($authData[32]);
                                $offset = 37; // RP ID Hash(32) + Flags(1) + Counter(4)
                                if (($flags & 0x40) === 0x40) { // Attested credential data present
                                    $offset += 16; // AAGUID
                                    if (strlen($authData) >= $offset + 2) {
                                        $l = (ord($authData[$offset]) << 8) | ord($authData[$offset + 1]);
                                        $offset += 2 + $l; // Credential ID
                                        if (strlen($authData) > $offset) {
                                            $publicKeyData = substr($authData, $offset);
                                            try {
                                                // Extract just the public key (which is a CBOR object)
                                                $pkStream = new \Webauthn\StringStream($publicKeyData);
                                                $pkObject = \CBOR\Decoder::create()->decode($pkStream);
                                                return (new Encoder())->encode($pkObject);
                                            } catch (Exception $e) {
                                                // Not a valid CBOR object at the expected offset; fall through
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            } catch (Exception $e) {
                // Not a valid CBOR map or missing kty; proceed to PEM/DER checks
            }
        }

        if (!$this->looksLikePemOrDer($data)) {
            // Unknown binary; try returning as-is and let downstream throw if invalid.
            return $data;
        }

        // Normalize to PEM string
        $pem = $data;
        if (!str_contains($pem, '-----BEGIN')) {
            $pem = "-----BEGIN PUBLIC KEY-----\r\n" . chunk_split(base64_encode($data), 64, "\r\n") . "-----END PUBLIC KEY-----\r\n";
        }

        $key = @openssl_pkey_get_public($pem);
        if ($key === false) {
            throw new Exception('Invalid public key material (PEM/DER parse failed)');
        }
        $details = openssl_pkey_get_details($key);
        if (!is_array($details) || !isset($details['type'])) {
            throw new Exception('Could not extract public key details');
        }
        if ((int)$details['type'] !== OPENSSL_KEYTYPE_EC) {
            throw new Exception('Unsupported key type for WebAuthn (expected EC)');
        }

        $curve = $details['ec']['curve_name'] ?? null;
        $point = $details['ec']['public_key'] ?? null;
        $x = $details['ec']['x'] ?? null;
        $y = $details['ec']['y'] ?? null;

        if ($x === null || $y === null) {
            // Try to parse uncompressed point (0x04 || X || Y)
            if (!is_string($point) || $point === '' || $point[0] !== "\x04") {
                throw new Exception('Unable to extract EC public key coordinates');
            }
            $coordLen = (strlen($point) - 1) / 2;
            if ($coordLen <= 0) {
                throw new Exception('Invalid EC public key point');
            }
            $x = substr($point, 1, (int)$coordLen);
            $y = substr($point, 1 + (int)$coordLen, (int)$coordLen);
        }

        // Map curve to COSE crv id
        $crv = 1; // default P-256
        if (is_string($curve)) {
            $lc = strtolower($curve);
            if (str_contains($lc, 'secp256') || str_contains($lc, 'prime256') || str_contains($lc, 'p-256')) {
                $crv = 1; // P-256
            } elseif (str_contains($lc, 'secp384') || str_contains($lc, 'p-384')) {
                $crv = 2; // P-384
            } elseif (str_contains($lc, 'secp521') || str_contains($lc, 'p-521')) {
                $crv = 3; // P-521
            } else {
                throw new Exception('Unsupported EC curve: ' . $curve);
            }
        }

        // Map algorithm string to COSE alg id (defaults to ES256)
        $alg = -7; // ES256
        $algStr = strtoupper((string)$algorithm);
        if ($algStr === 'ES384') { $alg = -35; }
        elseif ($algStr === 'ES512') { $alg = -36; }

        // Build COSE key map and CBOR-encode it
        $cose = [
            1 => 2,     // kty: EC2
            3 => $alg,  // alg
            -1 => $crv, // crv
            -2 => $x,   // x-coordinate (bytes)
            -3 => $y,   // y-coordinate (bytes)
        ];

        $encoder = new Encoder();
        return $encoder->encode($cose);
    }
}
