<?php
/**
 * Antara — WebAuthn / Passkey server-side (no Composer, no vendor lib).
 *
 * Spec coverage:
 *   - Registration (navigator.credentials.create) with attestation: "none".
 *   - Authentication (navigator.credentials.get), allow-list and discoverable.
 *   - Algorithms: ES256 (-7) and RS256 (-257).
 *   - userVerification: "required".
 *
 * The challenge is created on the *Begin* call and parked in the user session
 * under WEBAUTHN_REG_CHALLENGE / WEBAUTHN_LOGIN_CHALLENGE; the matching
 * *Finish* call validates and consumes it (one-shot).
 */

require_once __DIR__ . '/../security/crypto.php';
require_once __DIR__ . '/store.php';
require_once __DIR__ . '/session.php';
require_once __DIR__ . '/cbor.php';
require_once __DIR__ . '/cose.php';

final class AntaraWebAuthn
{
    private const SESS_REG = '_webauthn_reg';
    private const SESS_LOGIN = '_webauthn_login';

    public static function rpId(array $cfg): string
    {
        $rp = $cfg['rp_id'] ?? null;
        if (is_string($rp) && $rp !== '') return $rp;
        $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
        // Strip port if any (RP ID is a domain, not host:port).
        $host = preg_replace('/:\d+$/', '', $host);
        return $host;
    }

    public static function rpName(array $cfg): string
    {
        return (string)($cfg['rp_name'] ?? 'Antara');
    }

    public static function expectedOrigin(): string
    {
        $scheme = (
            (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
            || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
        ) ? 'https' : 'http';
        $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
        return $scheme . '://' . $host;
    }

    /* ============================================================
       REGISTRATION
       ============================================================ */

    public static function registerBegin(array $user, array $cfg): array
    {
        AntaraSession::start();
        $challenge = random_bytes(32);
        $userIdRaw = hex2bin(str_replace('-', '', $user['id']));

        // Pull existing creds to send as excludeCredentials.
        $exclude = [];
        foreach (AntaraStore::credsForUser($user['id']) as $c) {
            $exclude[] = ['type' => 'public-key', 'id' => $c['credentialId']];
        }

        $_SESSION[self::SESS_REG] = [
            'challenge' => AntaraCrypto::b64u_encode($challenge),
            'userId'    => $user['id'],
            'createdAt' => time(),
        ];

        return [
            'challenge' => AntaraCrypto::b64u_encode($challenge),
            'rp' => [
                'name' => self::rpName($cfg),
                'id'   => self::rpId($cfg),
            ],
            'user' => [
                'id'          => AntaraCrypto::b64u_encode($userIdRaw),
                'name'        => (string)($user['email'] ?: $user['username']),
                'displayName' => (string)($user['username'] ?: $user['email']),
            ],
            'pubKeyCredParams' => [
                ['type' => 'public-key', 'alg' => AntaraCose::ALG_ES256],
                ['type' => 'public-key', 'alg' => AntaraCose::ALG_RS256],
            ],
            'timeout'     => 60000,
            'attestation' => 'none',
            'authenticatorSelection' => [
                'authenticatorAttachment' => 'platform',
                'userVerification'        => 'required',
                'residentKey'             => 'preferred',
                'requireResidentKey'      => false,
            ],
            'excludeCredentials' => $exclude,
        ];
    }

    /**
     * @param array $payload {
     *   id: string (b64url),
     *   rawId: string (b64url),
     *   type: 'public-key',
     *   response: { clientDataJSON: b64url, attestationObject: b64url, transports?: string[] }
     * }
     */
    public static function registerFinish(array $payload, array $cfg): array
    {
        AntaraSession::start();
        $reg = $_SESSION[self::SESS_REG] ?? null;
        unset($_SESSION[self::SESS_REG]); // one-shot
        if (!$reg) throw new RuntimeException('No registration challenge in session.');

        $userId = $reg['userId'];
        $expectedChallenge = $reg['challenge'];

        $resp = $payload['response'] ?? [];
        $clientDataJsonB64 = $resp['clientDataJSON'] ?? '';
        $attObjB64         = $resp['attestationObject'] ?? '';
        if (!$clientDataJsonB64 || !$attObjB64) {
            throw new RuntimeException('Malformed registration response.');
        }

        $clientDataJson = AntaraCrypto::b64u_decode($clientDataJsonB64);
        $clientData = json_decode($clientDataJson, true);
        if (!is_array($clientData)) throw new RuntimeException('Invalid clientDataJSON.');
        if (($clientData['type'] ?? '') !== 'webauthn.create') throw new RuntimeException('Wrong clientData type.');
        if (($clientData['challenge'] ?? '') !== $expectedChallenge) throw new RuntimeException('Challenge mismatch.');
        if (($clientData['origin'] ?? '') !== self::expectedOrigin()) throw new RuntimeException('Origin mismatch.');

        $attObj = AntaraCrypto::b64u_decode($attObjB64);
        $pos = 0;
        $att = AntaraCbor::decodeFirst($attObj, $pos);
        if (!is_array($att) || empty($att['authData'])) throw new RuntimeException('Bad attestationObject.');
        if (($att['fmt'] ?? '') !== 'none') {
            // Accept only "none" attestation in this minimal implementation.
            // Apple/Android may send "packed" / "android-safetynet"; reject for clarity.
            // Browsers honor attestation: 'none' so this should not occur in practice.
            throw new RuntimeException('Only attestation "none" is supported.');
        }
        $authData = $att['authData'];
        $parsed = self::parseAuthData($authData, true);

        $rpId = self::rpId($cfg);
        if (!hash_equals(hash('sha256', $rpId, true), $parsed['rpIdHash'])) {
            throw new RuntimeException('rpIdHash mismatch.');
        }
        if (($parsed['flags'] & 0x01) === 0) throw new RuntimeException('User Presence flag not set.');
        if (($parsed['flags'] & 0x04) === 0) throw new RuntimeException('User Verification required.');
        if (empty($parsed['credentialId']) || empty($parsed['credentialPublicKey'])) {
            throw new RuntimeException('Attested credential data missing.');
        }

        $coseRes = AntaraCose::coseKeyToPem($parsed['credentialPublicKey']);

        $credIdB64 = AntaraCrypto::b64u_encode($parsed['credentialId']);
        $now = gmdate('Y-m-d\TH:i:s\Z');

        // Reject duplicates
        if (AntaraStore::credByCredentialId($credIdB64) !== null) {
            throw new RuntimeException('Credential already registered.');
        }

        AntaraStore::addCredential([
            'userId'       => $userId,
            'credentialId' => $credIdB64,
            'publicKeyPem' => $coseRes['pem'],
            'coseAlg'      => $coseRes['alg'],
            'signCount'    => $parsed['signCount'],
            'transports'   => $resp['transports'] ?? [],
            'aaguid'       => bin2hex($parsed['aaguid'] ?? ''),
            'createdAt'    => $now,
            'lastUsedAt'   => null,
        ]);

        return [
            'credentialId' => $credIdB64,
            'userId'       => $userId,
        ];
    }

    /* ============================================================
       AUTHENTICATION
       ============================================================ */

    public static function loginBegin(array $cfg, ?string $login = null): array
    {
        AntaraSession::start();
        $challenge = random_bytes(32);

        $allow = [];
        if ($login) {
            $u = AntaraStore::findUserByLogin($login);
            if ($u) {
                foreach (AntaraStore::credsForUser($u['id']) as $c) {
                    $allow[] = [
                        'type' => 'public-key',
                        'id'   => $c['credentialId'],
                        'transports' => $c['transports'] ?? ['internal', 'hybrid'],
                    ];
                }
            }
        }

        $_SESSION[self::SESS_LOGIN] = [
            'challenge' => AntaraCrypto::b64u_encode($challenge),
            'login'     => $login,
            'createdAt' => time(),
        ];

        return [
            'challenge'        => AntaraCrypto::b64u_encode($challenge),
            'rpId'             => self::rpId($cfg),
            'timeout'          => 60000,
            'userVerification' => 'required',
            'allowCredentials' => $allow, // empty array = discoverable
        ];
    }

    /**
     * @param array $payload {
     *   id, rawId, type,
     *   response: { clientDataJSON, authenticatorData, signature, userHandle? }
     * }
     */
    public static function loginFinish(array $payload, array $cfg): array
    {
        AntaraSession::start();
        $st = $_SESSION[self::SESS_LOGIN] ?? null;
        unset($_SESSION[self::SESS_LOGIN]); // one-shot
        if (!$st) throw new RuntimeException('No login challenge in session.');
        $expectedChallenge = $st['challenge'];

        $resp = $payload['response'] ?? [];
        $clientDataJsonB64 = $resp['clientDataJSON'] ?? '';
        $authDataB64       = $resp['authenticatorData'] ?? '';
        $sigB64            = $resp['signature'] ?? '';
        $credIdB64         = $payload['id'] ?? '';
        if (!$clientDataJsonB64 || !$authDataB64 || !$sigB64 || !$credIdB64) {
            throw new RuntimeException('Malformed authentication response.');
        }

        $cred = AntaraStore::credByCredentialId($credIdB64);
        if (!$cred) throw new RuntimeException('Unknown credential.');

        $clientDataJson = AntaraCrypto::b64u_decode($clientDataJsonB64);
        $clientData = json_decode($clientDataJson, true);
        if (!is_array($clientData)) throw new RuntimeException('Invalid clientDataJSON.');
        if (($clientData['type'] ?? '') !== 'webauthn.get') throw new RuntimeException('Wrong clientData type.');
        if (($clientData['challenge'] ?? '') !== $expectedChallenge) throw new RuntimeException('Challenge mismatch.');
        if (($clientData['origin'] ?? '') !== self::expectedOrigin()) throw new RuntimeException('Origin mismatch.');

        $authData = AntaraCrypto::b64u_decode($authDataB64);
        $parsed = self::parseAuthData($authData, false);
        $rpId = self::rpId($cfg);
        if (!hash_equals(hash('sha256', $rpId, true), $parsed['rpIdHash'])) {
            throw new RuntimeException('rpIdHash mismatch.');
        }
        if (($parsed['flags'] & 0x01) === 0) throw new RuntimeException('User Presence flag not set.');
        if (($parsed['flags'] & 0x04) === 0) throw new RuntimeException('User Verification required.');

        $signature = AntaraCrypto::b64u_decode($sigB64);
        $signedBytes = $authData . hash('sha256', $clientDataJson, true);

        $algo = AntaraCose::opensslAlgo((int)$cred['coseAlg']);
        $ok = openssl_verify($signedBytes, $signature, $cred['publicKeyPem'], $algo);
        if ($ok !== 1) {
            throw new RuntimeException('Signature verification failed.');
        }

        // Sign-counter check (allow 0 / 0 from authenticators that do not increment).
        $newCount = $parsed['signCount'];
        $oldCount = (int)($cred['signCount'] ?? 0);
        if ($newCount !== 0 && $newCount <= $oldCount) {
            throw new RuntimeException('Sign counter did not advance (possible cloned authenticator).');
        }
        AntaraStore::updateCredentialSignCount($credIdB64, $newCount);

        $user = AntaraStore::findUserById($cred['userId']);
        if (!$user) throw new RuntimeException('Credential references unknown user.');
        return ['user' => $user, 'credential' => $cred];
    }

    /* ============================================================
       authData parser
       layout: rpIdHash(32) | flags(1) | signCount(4) | [AT] aaguid(16) credIdLen(2) credId(N) credPublicKey(CBOR) | [ED]
       ============================================================ */
    private static function parseAuthData(string $authData, bool $expectAttested): array
    {
        if (strlen($authData) < 37) throw new RuntimeException('authData too short.');
        $rpIdHash = substr($authData, 0, 32);
        $flags = ord($authData[32]);
        $signCount = unpack('N', substr($authData, 33, 4))[1];
        $offset = 37;
        $out = [
            'rpIdHash'  => $rpIdHash,
            'flags'     => $flags,
            'signCount' => (int)$signCount,
            'aaguid'    => null,
            'credentialId' => null,
            'credentialPublicKey' => null,
        ];

        $hasAt = ($flags & 0x40) !== 0;
        if ($expectAttested && !$hasAt) throw new RuntimeException('AT flag missing for registration.');

        if ($hasAt) {
            if (strlen($authData) < $offset + 18) throw new RuntimeException('Truncated attestedCredentialData.');
            $aaguid = substr($authData, $offset, 16); $offset += 16;
            $credIdLen = unpack('n', substr($authData, $offset, 2))[1]; $offset += 2;
            if (strlen($authData) < $offset + $credIdLen) throw new RuntimeException('Truncated credentialId.');
            $credentialId = substr($authData, $offset, $credIdLen); $offset += $credIdLen;
            $coseStart = $offset;
            $cose = AntaraCbor::decodeFirst($authData, $offset);
            if (!is_array($cose)) throw new RuntimeException('Bad credentialPublicKey.');
            $out['aaguid'] = $aaguid;
            $out['credentialId'] = $credentialId;
            $out['credentialPublicKey'] = $cose;
        }

        return $out;
    }
}
