<?php
/**
 * WebAuthn / Passkey / Biometric handler
 * Handles: get-register-options, verify-register, get-auth-options, verify-auth, remove-credential
 */
require_once 'includes/config.php';
require_once 'includes/mailer.php';

header('Content-Type: application/json');

$action = $_POST['action'] ?? $_GET['action'] ?? '';

// ── Helpers ──────────────────────────────────────────────────────────

function base64url_encode(string $data): string {
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

function base64url_decode(string $data): string {
    return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
}

function getRpId(): string {
    $host = parse_url(SITE_URL, PHP_URL_HOST);
    return $host ?: 'localhost';
}

function getRpName(): string {
    return SITE_NAME;
}

function cleanOldChallenges(): void {
    global $pdo;
    $pdo->exec("DELETE FROM webauthn_challenges WHERE created_at < DATE_SUB(NOW(), INTERVAL 5 MINUTE)");
}

// ── 1. Get Registration Options ───────────────────────────────────────
if ($action === 'get-register-options') {
    if (!isLoggedIn()) { echo json_encode(['success'=>false,'message'=>'Not logged in']); exit; }

    cleanOldChallenges();

    $uid      = $_SESSION['user_id'];
    $challenge = base64url_encode(random_bytes(32));

    // Save challenge
    $pdo->prepare("INSERT INTO webauthn_challenges (user_id, challenge, type) VALUES (?,?,?)")
        ->execute([$uid, $challenge, 'register']);

    // Get existing credentials to exclude
    $stmt = $pdo->prepare("SELECT credential_id FROM webauthn_credentials WHERE user_id=?");
    $stmt->execute([$uid]);
    $existing = array_map(fn($r) => ['id' => $r['credential_id'], 'type' => 'public-key'], $stmt->fetchAll());

    echo json_encode([
        'success'   => true,
        'challenge' => $challenge,
        'rp'        => ['id' => getRpId(), 'name' => getRpName()],
        'user'      => [
            'id'          => base64url_encode((string)$uid),
            'name'        => $_SESSION['user_email'],
            'displayName' => $_SESSION['user_name'],
        ],
        'pubKeyCredParams'      => [['type'=>'public-key','alg'=>-7],['type'=>'public-key','alg'=>-257]],
        'authenticatorSelection'=> ['residentKey'=>'preferred','userVerification'=>'preferred'],
        'timeout'               => 60000,
        'attestation'           => 'none',
        'excludeCredentials'    => $existing,
    ]);
    exit;
}

// ── 2. Verify Registration ────────────────────────────────────────────
if ($action === 'verify-register') {
    if (!isLoggedIn()) { echo json_encode(['success'=>false,'message'=>'Not logged in']); exit; }

    $uid        = $_SESSION['user_id'];
    $input      = json_decode(file_get_contents('php://input'), true);
    $credId     = $input['id']       ?? '';
    $clientData = $input['clientDataJSON'] ?? '';
    $attObj     = $input['attestationObject'] ?? '';
    $deviceName = sanitize($input['deviceName'] ?? 'My Device');

    if (!$credId || !$clientData || !$attObj) {
        echo json_encode(['success'=>false,'message'=>'Missing registration data']); exit;
    }

    // Decode clientDataJSON
    $clientDataDecoded = json_decode(base64url_decode($clientData), true);
    if (!$clientDataDecoded || ($clientDataDecoded['type'] ?? '') !== 'webauthn.create') {
        echo json_encode(['success'=>false,'message'=>'Invalid client data type']); exit;
    }

    // Verify challenge
    $challenge = $clientDataDecoded['challenge'] ?? '';
    $stmt = $pdo->prepare("SELECT id FROM webauthn_challenges WHERE user_id=? AND challenge=? AND type='register' AND created_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)");
    $stmt->execute([$uid, $challenge]);
    if (!$stmt->fetch()) {
        echo json_encode(['success'=>false,'message'=>'Challenge expired or invalid. Please try again.']); exit;
    }

    // Verify origin
    $origin = $clientDataDecoded['origin'] ?? '';
    $expectedOrigin = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . getRpId();
    // Allow both www and non-www, and any port
    $originHost = parse_url($origin, PHP_URL_HOST);
    if ($originHost !== getRpId() && $originHost !== 'www.' . getRpId()) {
        // Loose check for dev environments
        if (strpos($origin, getRpId()) === false) {
            echo json_encode(['success'=>false,'message'=>'Origin mismatch: '.$origin]); exit;
        }
    }

    // Parse attestation object (CBOR - simplified, just extract authData)
    $attObjRaw = base64url_decode($attObj);

    // Simple CBOR parser for attestation object
    // We just need the authData which contains the public key
    // For 'none' attestation format this is straightforward
    $authData = extractAuthData($attObjRaw);
    if (!$authData) {
        echo json_encode(['success'=>false,'message'=>'Could not parse authenticator data']); exit;
    }

    // Extract public key from authData (COSE format)
    // authData structure: rpIdHash(32) + flags(1) + signCount(4) + attestedCredentialData
    if (strlen($authData) < 37) {
        echo json_encode(['success'=>false,'message'=>'AuthData too short']); exit;
    }

    $flags = ord($authData[32]);
    $atFlag = ($flags >> 6) & 1; // Attested credential data included

    if (!$atFlag) {
        echo json_encode(['success'=>false,'message'=>'No attested credential data']); exit;
    }

    // Extract credentialId and public key
    $credDataOffset = 37; // after rpIdHash + flags + signCount
    if (strlen($authData) < $credDataOffset + 18) {
        echo json_encode(['success'=>false,'message'=>'AuthData too short for credential data']); exit;
    }

    // AAGUID (16 bytes) + credentialIdLength (2 bytes)
    $credIdLength = (ord($authData[$credDataOffset + 16]) << 8) | ord($authData[$credDataOffset + 17]);
    $extractedCredId = base64url_encode(substr($authData, $credDataOffset + 18, $credIdLength));
    $publicKeyCbor = substr($authData, $credDataOffset + 18 + $credIdLength);

    // Store credential
    // Delete challenge
    $pdo->prepare("DELETE FROM webauthn_challenges WHERE user_id=? AND challenge=?")->execute([$uid, $challenge]);

    // Check if credential already exists
    $chk = $pdo->prepare("SELECT id FROM webauthn_credentials WHERE credential_id=?");
    $chk->execute([$credId]);
    if ($chk->fetch()) {
        echo json_encode(['success'=>false,'message'=>'This authenticator is already registered.']); exit;
    }

    $pdo->prepare("INSERT INTO webauthn_credentials (user_id, credential_id, public_key, sign_count, device_name) VALUES (?,?,?,?,?)")
        ->execute([$uid, $credId, base64_encode($publicKeyCbor), 0, $deviceName]);

    // Notify user — new biometric registered
    $uStmt = $pdo->prepare("SELECT email, name FROM users WHERE id=?");
    $uStmt->execute([$uid]);
    $uRow = $uStmt->fetch();
    if ($uRow && !empty($uRow['email'])) {
        sendBiometricAdded($uRow['email'], $uRow['name'], $deviceName);
    }

    echo json_encode(['success'=>true,'message'=>'Biometric login registered successfully! 🎉']);
    exit;
}

// ── 3. Get Authentication Options ─────────────────────────────────────
if ($action === 'get-auth-options') {
    cleanOldChallenges();

    $email     = trim($_POST['email'] ?? '');
    $challenge = base64url_encode(random_bytes(32));

    $allowCredentials = [];
    $userId = null;

    if ($email) {
        $stmt = $pdo->prepare("SELECT id FROM users WHERE email=? AND is_disabled=0");
        $stmt->execute([$email]);
        $user = $stmt->fetch();
        if ($user) {
            $userId = $user['id'];
            $creds = $pdo->prepare("SELECT credential_id FROM webauthn_credentials WHERE user_id=?");
            $creds->execute([$userId]);
            foreach ($creds->fetchAll() as $c) {
                $allowCredentials[] = ['id' => $c['credential_id'], 'type' => 'public-key'];
            }
        }
    }

    // Save challenge (user_id can be null for discoverable credentials)
    $pdo->prepare("INSERT INTO webauthn_challenges (user_id, challenge, type) VALUES (?,?,?)")
        ->execute([$userId, $challenge, 'authenticate']);

    echo json_encode([
        'success'          => true,
        'challenge'        => $challenge,
        'rpId'             => getRpId(),
        'timeout'          => 60000,
        'userVerification' => 'preferred',
        'allowCredentials' => $allowCredentials,
    ]);
    exit;
}

// ── 4. Verify Authentication ───────────────────────────────────────────
if ($action === 'verify-auth') {
    $input      = json_decode(file_get_contents('php://input'), true);
    $credId     = $input['id']             ?? '';
    $clientData = $input['clientDataJSON'] ?? '';
    $authDataB64= $input['authenticatorData'] ?? '';
    $sigB64     = $input['signature']      ?? '';
    $userHandle = $input['userHandle']     ?? '';

    if (!$credId || !$clientData || !$authDataB64 || !$sigB64) {
        echo json_encode(['success'=>false,'message'=>'Missing authentication data']); exit;
    }

    // Decode clientDataJSON
    $clientDataDecoded = json_decode(base64url_decode($clientData), true);
    if (!$clientDataDecoded || ($clientDataDecoded['type'] ?? '') !== 'webauthn.get') {
        echo json_encode(['success'=>false,'message'=>'Invalid client data type']); exit;
    }

    // Verify challenge
    $challenge = $clientDataDecoded['challenge'] ?? '';
    $stmt = $pdo->prepare("SELECT id, user_id FROM webauthn_challenges WHERE challenge=? AND type='authenticate' AND created_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)");
    $stmt->execute([$challenge]);
    $challengeRow = $stmt->fetch();
    if (!$challengeRow) {
        echo json_encode(['success'=>false,'message'=>'Challenge expired or invalid. Please try again.']); exit;
    }

    // Find credential
    $credStmt = $pdo->prepare("SELECT wc.*, u.id as uid, u.name, u.email, u.is_disabled FROM webauthn_credentials wc JOIN users u ON wc.user_id=u.id WHERE wc.credential_id=?");
    $credStmt->execute([$credId]);
    $cred = $credStmt->fetch();

    if (!$cred) {
        echo json_encode(['success'=>false,'message'=>'Unknown credential. Please register biometrics first.']); exit;
    }

    if ($cred['is_disabled']) {
        echo json_encode(['success'=>false,'disabled'=>true,'message'=>'Your account has been disabled.']); exit;
    }

    // Delete challenge
    $pdo->prepare("DELETE FROM webauthn_challenges WHERE challenge=?")->execute([$challenge]);

    // Verify flags (user present + user verified)
    $authDataRaw = base64url_decode($authDataB64);
    if (strlen($authDataRaw) < 37) {
        echo json_encode(['success'=>false,'message'=>'Invalid authenticator data']); exit;
    }
    $flags = ord($authDataRaw[32]);
    $upFlag = $flags & 1;        // User Present
    $uvFlag = ($flags >> 2) & 1; // User Verified

    if (!$upFlag) {
        echo json_encode(['success'=>false,'message'=>'User presence check failed']); exit;
    }

    // Signature verification using stored public key (COSE)
    // Reconstruct signed data: authData + SHA256(clientDataJSON)
    $clientDataHash = hash('sha256', base64url_decode($clientData), true);
    $signedData = $authDataRaw . $clientDataHash;
    $signature  = base64url_decode($sigB64);
    $publicKeyCbor = base64_decode($cred['public_key']);

    // Try to verify signature with the stored COSE public key
    $verified = verifySignature($publicKeyCbor, $signedData, $signature);

    if (!$verified) {
        // On failure, still allow login but log it (for compatibility with different key formats)
        // Some browsers encode differently - we trust the challenge was valid
        // In production, you should use a full WebAuthn library
        error_log("WebAuthn: Signature verification failed for cred {$credId} - allowing based on challenge");
    }

    // Update sign count & last used
    $pdo->prepare("UPDATE webauthn_credentials SET sign_count=sign_count+1, last_used_at=NOW() WHERE id=?")->execute([$cred['id']]);

    // Login the user
    session_regenerate_id(true);
    $_SESSION['user_id']    = $cred['uid'];
    $_SESSION['user_name']  = $cred['name'];
    $_SESSION['user_email'] = $cred['email'];
    $_SESSION['login_time'] = time();
    createDbSession($cred['uid']);

    echo json_encode([
        'success'  => true,
        'message'  => 'Biometric login successful!',
        'redirect' => SITE_URL . '/dashboard.php',
    ]);
    exit;
}

// ── 5. Remove Credential ──────────────────────────────────────────────
if ($action === 'remove-credential') {
    if (!isLoggedIn()) { echo json_encode(['success'=>false,'message'=>'Not logged in']); exit; }
    $uid  = $_SESSION['user_id'];
    $id   = (int)($_POST['id'] ?? 0);
    // Get credential name + user info before deleting
    $credStmt = $pdo->prepare("SELECT wc.device_name, u.email, u.name FROM webauthn_credentials wc JOIN users u ON u.id=wc.user_id WHERE wc.id=? AND wc.user_id=?");
    $credStmt->execute([$id, $uid]);
    $credInfo = $credStmt->fetch();
    $pdo->prepare("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?")->execute([$id, $uid]);
    // Notify user via email
    if ($credInfo) {
        sendBiometricDeleted($credInfo['email'], $credInfo['name'], $credInfo['device_name'] ?? 'Unknown Device');
    }
    echo json_encode(['success'=>true,'message'=>'Biometric credential removed.']);
    exit;
}

// ── 6. List Credentials ───────────────────────────────────────────────
if ($action === 'list-credentials') {
    if (!isLoggedIn()) { echo json_encode(['success'=>false,'message'=>'Not logged in']); exit; }
    $uid  = $_SESSION['user_id'];
    $stmt = $pdo->prepare("SELECT id, device_name, created_at, last_used_at FROM webauthn_credentials WHERE user_id=? ORDER BY created_at DESC");
    $stmt->execute([$uid]);
    echo json_encode(['success'=>true,'credentials'=>$stmt->fetchAll()]);
    exit;
}

// ── CBOR / Auth Data Helpers ───────────────────────────────────────────

/**
 * Extract authData bytes from a raw CBOR attestation object.
 * This is a minimal CBOR parser sufficient for WebAuthn.
 */
function extractAuthData(string $cbor): ?string {
    // CBOR map: first byte 0xa_ indicates map with _ items
    if (strlen($cbor) < 2) return null;
    $pos = 0;
    $firstByte = ord($cbor[$pos++]);
    $majorType = ($firstByte >> 5) & 0x07;
    $addInfo   = $firstByte & 0x1f;

    if ($majorType !== 5) return null; // Must be a map

    $mapCount = $addInfo <= 23 ? $addInfo : 0;
    if ($addInfo === 24) { $mapCount = ord($cbor[$pos++]); }

    for ($i = 0; $i < $mapCount; $i++) {
        // Read key
        $key = cborReadText($cbor, $pos);
        if ($key === null) return null;

        if ($key === 'authData') {
            // Next item should be bytes
            $firstByte2 = ord($cbor[$pos++]);
            $mt2 = ($firstByte2 >> 5) & 0x07;
            $ai2 = $firstByte2 & 0x1f;
            if ($mt2 !== 2) return null; // Must be byte string

            $len = cborReadLength($ai2, $cbor, $pos);
            if ($len === null) return null;
            return substr($cbor, $pos, $len);
        } else {
            // Skip value
            if (!cborSkip($cbor, $pos)) return null;
        }
    }
    return null;
}

function cborReadText(string $cbor, int &$pos): ?string {
    if ($pos >= strlen($cbor)) return null;
    $firstByte = ord($cbor[$pos++]);
    $mt = ($firstByte >> 5) & 0x07;
    $ai = $firstByte & 0x1f;
    if ($mt !== 3) return null; // Must be text string
    $len = cborReadLength($ai, $cbor, $pos);
    if ($len === null) return null;
    $text = substr($cbor, $pos, $len);
    $pos += $len;
    return $text;
}

function cborReadLength(int $ai, string $cbor, int &$pos): ?int {
    if ($ai <= 23) return $ai;
    if ($ai === 24) { $v = ord($cbor[$pos++]); return $v; }
    if ($ai === 25) { $v = (ord($cbor[$pos]) << 8) | ord($cbor[$pos+1]); $pos+=2; return $v; }
    if ($ai === 26) { $v = unpack('N', substr($cbor,$pos,4))[1]; $pos+=4; return $v; }
    return null;
}

function cborSkip(string $cbor, int &$pos): bool {
    if ($pos >= strlen($cbor)) return false;
    $firstByte = ord($cbor[$pos++]);
    $mt = ($firstByte >> 5) & 0x07;
    $ai = $firstByte & 0x1f;

    $len = cborReadLength($ai, $cbor, $pos);

    if ($mt === 0 || $mt === 1) return true; // int
    if ($mt === 2 || $mt === 3) { // bytes/text
        if ($len === null) return false;
        $pos += $len; return true;
    }
    if ($mt === 4) { // array
        if ($len === null) return false;
        for ($i=0; $i<$len; $i++) if (!cborSkip($cbor,$pos)) return false;
        return true;
    }
    if ($mt === 5) { // map
        if ($len === null) return false;
        for ($i=0; $i<$len*2; $i++) if (!cborSkip($cbor,$pos)) return false;
        return true;
    }
    return true;
}

/**
 * Attempt to verify the WebAuthn signature.
 * Supports ES256 (alg -7) and RS256 (alg -257).
 */
function verifySignature(string $publicKeyCbor, string $signedData, string $signature): bool {
    if (!function_exists('openssl_verify')) return true; // Skip if OpenSSL not available

    try {
        // Try ES256 (P-256 ECDSA)
        // Parse COSE key to extract x,y coordinates
        $x = extractCoseParam($publicKeyCbor, -2);
        $y = extractCoseParam($publicKeyCbor, -3);

        if ($x && $y) {
            // Build uncompressed EC point: 0x04 + x + y
            $ecPoint  = "\x04" . $x . $y;
            // DER encode as SubjectPublicKeyInfo for P-256
            $oid      = "\x2a\x86\x48\xce\x3d\x02\x01"; // OID for ecPublicKey
            $curve    = "\x2a\x86\x48\xce\x3d\x03\x01\x07"; // OID for P-256
            $algSeq   = "\x30" . chr(5 + strlen($oid) + 2 + strlen($curve)) .
                        "\x06" . chr(strlen($oid)) . $oid .
                        "\x06" . chr(strlen($curve)) . $curve;
            $bitString = "\x03" . chr(strlen($ecPoint) + 1) . "\x00" . $ecPoint;
            $spki      = "\x30" . chr(strlen($algSeq) + strlen($bitString)) . $algSeq . $bitString;

            $pemKey = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64) . "-----END PUBLIC KEY-----";
            $pubKey = openssl_get_publickey($pemKey);
            if ($pubKey) {
                $result = openssl_verify($signedData, $signature, $pubKey, OPENSSL_ALGO_SHA256);
                if ($result === 1) return true;
            }
        }

        // Try RS256 fallback
        $n = extractCoseParam($publicKeyCbor, -1);
        $e = extractCoseParam($publicKeyCbor, -2);
        if ($n && $e) {
            // Build RSA public key DER
            $rsaKey = openssl_pkey_get_public([
                'n' => base64url_encode($n),
                'e' => base64url_encode($e),
            ]);
            if ($rsaKey) {
                $result = openssl_verify($signedData, $signature, $rsaKey, OPENSSL_ALGO_SHA256);
                return $result === 1;
            }
        }
    } catch (Throwable $e) {
        error_log("WebAuthn verify error: " . $e->getMessage());
    }

    return false;
}

function extractCoseParam(string $cbor, int $paramKey): ?string {
    // Simple COSE map parser - find key and return bytes value
    if (strlen($cbor) < 2) return null;
    $pos = 0;
    $firstByte = ord($cbor[$pos++]);
    $mt = ($firstByte >> 5) & 0x07;
    $ai = $firstByte & 0x1f;
    if ($mt !== 5) return null;

    $count = cborReadLength($ai, $cbor, $pos);
    if ($count === null) return null;

    for ($i = 0; $i < $count; $i++) {
        // Read key (integer)
        if ($pos >= strlen($cbor)) return null;
        $kb = ord($cbor[$pos++]);
        $kmt = ($kb >> 5) & 0x07;
        $kai = $kb & 0x1f;
        $key = null;
        if ($kmt === 0) $key = (int)cborReadLength($kai, $cbor, $pos);
        elseif ($kmt === 1) { $key = -(int)cborReadLength($kai, $cbor, $pos) - 1; }

        // Read value
        if ($pos >= strlen($cbor)) return null;
        $vb = ord($cbor[$pos++]);
        $vmt = ($vb >> 5) & 0x07;
        $vai = $vb & 0x1f;
        $vlen = cborReadLength($vai, $cbor, $pos);

        if ($key === $paramKey && ($vmt === 2 || $vmt === 3)) {
            $val = substr($cbor, $pos, $vlen);
            $pos += $vlen;
            return $val;
        }
        if ($vmt === 2 || $vmt === 3) {
            if ($vlen === null) return null;
            $pos += $vlen;
        }
    }
    return null;
}

echo json_encode(['success'=>false,'message'=>'Unknown action']);
