<?php
namespace EasyChat\Library;

class PasskeyService {
    const CHALLENGE_TTL_SECONDS = 300;

    private $registry;
    private $db;
    private $prefix;

    public function __construct($registry) {
        $this->registry = $registry;
        $this->db = $registry->get('db');
        $this->prefix = easychat_db_prefix();
    }

    public function registrationOptions(array $manager): array {
        $challenge = self::base64UrlEncode(random_bytes(32));
        $this->createChallenge('register', $challenge, (int)$manager['id']);

        return array(
            'challenge' => $challenge,
            'rp' => array(
                'name' => 'EasyChat',
                'id' => $this->rpId(),
            ),
            'user' => array(
                'id' => self::base64UrlEncode(hash('sha256', 'easychat-manager|' . (int)$manager['id'], true)),
                'name' => (string)$manager['username'],
                'displayName' => (string)$manager['name'],
            ),
            'pubKeyCredParams' => array(
                array('type' => 'public-key', 'alg' => -7),
                array('type' => 'public-key', 'alg' => -257),
            ),
            'timeout' => 60000,
            'attestation' => 'none',
            'authenticatorSelection' => array(
                'residentKey' => 'required',
                'requireResidentKey' => true,
                'userVerification' => 'required',
            ),
        );
    }

    public function loginOptions(): array {
        $challenge = self::base64UrlEncode(random_bytes(32));
        $this->createChallenge('login', $challenge, 0);

        return array(
            'challenge' => $challenge,
            'rpId' => $this->rpId(),
            'timeout' => 60000,
            'userVerification' => 'required',
        );
    }

    public function verifyRegistration(array $payload, int $manager_id, string $device_label): array {
        $response = isset($payload['response']) && is_array($payload['response']) ? $payload['response'] : array();
        $client_data = $this->decodeJsonField($response, 'clientDataJSON');
        $this->verifyClientData($client_data, 'webauthn.create', 'register', $manager_id);

        $attestation = self::base64UrlDecode((string)($response['attestationObject'] ?? ''));
        $decoded = self::decodeCbor($attestation);
        if (!is_array($decoded) || !isset($decoded['authData'])) {
            throw new \RuntimeException('Некорректный ответ устройства.');
        }

        $auth_data = $this->parseAuthData($decoded['authData'], true);
        $public_key_cose = $auth_data['public_key_cose'];
        $credential_id = $auth_data['credential_id'];
        $alg = $this->coseAlgorithm($public_key_cose);
        if ($alg !== -7 && $alg !== -257) {
            throw new \RuntimeException('Это устройство использует неподдерживаемый алгоритм ключа.');
        }
        $this->publicKeyPemFromCose($public_key_cose, $alg);

        $credential_id_hash = hash('sha256', $credential_id);
        $manager_id = (int)$manager_id;
        $safe_label = $this->normalizeLabel($device_label);
        $transports = isset($response['transports']) && is_array($response['transports']) ? implode(',', array_slice(array_map('strval', $response['transports']), 0, 8)) : '';

        $this->db->query(
            "INSERT INTO `{$this->prefix}manager_passkey_credentials`
             (`manager_id`, `credential_id_hash`, `credential_id`, `public_key_cose`, `public_key_alg`, `sign_count`, `transports`, `device_label`, `user_agent_hash`, `created_at`)
             VALUES (" . $manager_id . ",
                '" . $this->db->escape($credential_id_hash) . "',
                '" . $this->db->escape(self::base64UrlEncode($credential_id)) . "',
                '" . $this->db->escape(self::base64UrlEncode(self::encodeCbor($public_key_cose))) . "',
                " . (int)$alg . ",
                " . (int)$auth_data['sign_count'] . ",
                '" . $this->db->escape($transports) . "',
                '" . $this->db->escape($safe_label) . "',
                '" . $this->db->escape($this->requestHash('HTTP_USER_AGENT')) . "',
                NOW())
             ON DUPLICATE KEY UPDATE
                `manager_id` = VALUES(`manager_id`),
                `public_key_cose` = VALUES(`public_key_cose`),
                `public_key_alg` = VALUES(`public_key_alg`),
                `sign_count` = VALUES(`sign_count`),
                `transports` = VALUES(`transports`),
                `device_label` = VALUES(`device_label`),
                `revoked_at` = NULL"
        );

        return array('success' => true);
    }

    public function verifyLogin(array $payload): array {
        $response = isset($payload['response']) && is_array($payload['response']) ? $payload['response'] : array();
        $client_data = $this->decodeJsonField($response, 'clientDataJSON');
        $this->verifyClientData($client_data, 'webauthn.get', 'login', 0);

        $credential_id = self::base64UrlDecode((string)($payload['rawId'] ?? $payload['id'] ?? ''));
        $credential = $this->credentialByRawId($credential_id);
        if (!$credential) {
            throw new \RuntimeException('Passkey не привязан к этой установке EasyChat.');
        }

        $authenticator_data = self::base64UrlDecode((string)($response['authenticatorData'] ?? ''));
        $parsed = $this->parseAuthData($authenticator_data, false);
        $this->assertUserPresentAndVerified($parsed['flags']);

        $signature = self::base64UrlDecode((string)($response['signature'] ?? ''));
        $signed_data = $authenticator_data . hash('sha256', self::base64UrlDecode((string)$response['clientDataJSON']), true);
        $public_key_cose = self::decodeCbor(self::base64UrlDecode((string)$credential['public_key_cose']));
        $alg = (int)$credential['public_key_alg'];
        $pem = $this->publicKeyPemFromCose($public_key_cose, $alg);
        $openssl_alg = $alg === -257 ? OPENSSL_ALGO_SHA256 : 'sha256';

        if (openssl_verify($signed_data, $signature, $pem, $openssl_alg) !== 1) {
            throw new \RuntimeException('Не удалось подтвердить подпись passkey.');
        }

        $old_count = (int)$credential['sign_count'];
        $new_count = (int)$parsed['sign_count'];
        if ($old_count > 0 && $new_count > 0 && $new_count <= $old_count) {
            throw new \RuntimeException('Passkey отклонен: счетчик устройства выглядит повторно использованным.');
        }

        $this->db->query(
            "UPDATE `{$this->prefix}manager_passkey_credentials`
             SET `sign_count` = " . $new_count . ", `last_used_at` = NOW()
             WHERE `id` = " . (int)$credential['credential_record_id'] . " LIMIT 1"
        );

        return array('success' => true, 'manager' => $credential);
    }

    public function listCredentials(int $manager_id): array {
        $query = $this->db->query(
            "SELECT `id`, `device_label`, `transports`, `created_at`, `last_used_at`
             FROM `{$this->prefix}manager_passkey_credentials`
             WHERE `manager_id` = " . (int)$manager_id . " AND `revoked_at` IS NULL
             ORDER BY `last_used_at` DESC, `created_at` DESC"
        );
        return $query && isset($query->rows) ? $query->rows : array();
    }

    public function revokeCredential(int $manager_id, int $credential_id): bool {
        $this->db->query(
            "UPDATE `{$this->prefix}manager_passkey_credentials`
             SET `revoked_at` = NOW()
             WHERE `id` = " . (int)$credential_id . " AND `manager_id` = " . (int)$manager_id . " LIMIT 1"
        );
        return true;
    }

    private function verifyClientData(array $client_data, string $type, string $purpose, int $manager_id): void {
        if (($client_data['type'] ?? '') !== $type) {
            throw new \RuntimeException('Некорректный тип WebAuthn-операции.');
        }
        $challenge = (string)($client_data['challenge'] ?? '');
        if (!$this->consumeChallenge($purpose, $challenge, $manager_id)) {
            throw new \RuntimeException('Срок действия проверки истек. Повторите вход.');
        }
        if (!$this->isAllowedOrigin((string)($client_data['origin'] ?? ''))) {
            throw new \RuntimeException('Passkey пришел с неподходящего домена.');
        }
    }

    private function decodeJsonField(array $response, string $field): array {
        $raw = self::base64UrlDecode((string)($response[$field] ?? ''));
        $data = json_decode($raw, true);
        if (!is_array($data)) {
            throw new \RuntimeException('Браузер вернул некорректные данные passkey.');
        }
        return $data;
    }

    private function parseAuthData(string $auth_data, bool $expect_attested): array {
        if (strlen($auth_data) < 37) {
            throw new \RuntimeException('Некорректные authenticatorData.');
        }
        $rp_hash = substr($auth_data, 0, 32);
        if (!hash_equals(hash('sha256', $this->rpId(), true), $rp_hash)) {
            throw new \RuntimeException('Passkey выпущен для другого домена.');
        }
        $flags = ord($auth_data[32]);
        $this->assertUserPresentAndVerified($flags);
        $count = unpack('N', substr($auth_data, 33, 4))[1];

        $result = array('flags' => $flags, 'sign_count' => $count);
        if (!$expect_attested) {
            return $result;
        }
        if (($flags & 0x40) !== 0x40 || strlen($auth_data) < 55) {
            throw new \RuntimeException('Устройство не вернуло credential.');
        }
        $pos = 37 + 16;
        $credential_len = unpack('n', substr($auth_data, $pos, 2))[1];
        $pos += 2;
        $credential_id = substr($auth_data, $pos, $credential_len);
        $pos += $credential_len;
        $public_key_cose = self::decodeCbor(substr($auth_data, $pos));
        if (!is_array($public_key_cose) || $credential_id === '') {
            throw new \RuntimeException('Некорректный публичный ключ passkey.');
        }
        $result['credential_id'] = $credential_id;
        $result['public_key_cose'] = $public_key_cose;
        return $result;
    }

    private function assertUserPresentAndVerified(int $flags): void {
        if (($flags & 0x01) !== 0x01 || ($flags & 0x04) !== 0x04) {
            throw new \RuntimeException('Подтвердите вход на устройстве через FaceID, TouchID, отпечаток или PIN.');
        }
    }

    private function createChallenge(string $purpose, string $challenge, int $manager_id): void {
        $this->cleanupChallenges();
        $this->db->query(
            "INSERT INTO `{$this->prefix}passkey_challenges`
             (`challenge_hash`, `purpose`, `manager_id`, `session_id_hash`, `ip_hash`, `user_agent_hash`, `expires_at`, `created_at`)
             VALUES (
                '" . $this->db->escape(hash('sha256', $challenge)) . "',
                '" . $this->db->escape($purpose) . "',
                " . (int)$manager_id . ",
                '" . $this->db->escape(hash('sha256', session_id())) . "',
                '" . $this->db->escape($this->requestHash('REMOTE_ADDR')) . "',
                '" . $this->db->escape($this->requestHash('HTTP_USER_AGENT')) . "',
                DATE_ADD(NOW(), INTERVAL " . self::CHALLENGE_TTL_SECONDS . " SECOND),
                NOW())"
        );
    }

    private function consumeChallenge(string $purpose, string $challenge, int $manager_id): bool {
        $where_manager = $manager_id > 0 ? " AND `manager_id` = " . (int)$manager_id : '';
        $hash = hash('sha256', $challenge);
        $query = $this->db->query(
            "SELECT `id` FROM `{$this->prefix}passkey_challenges`
             WHERE `challenge_hash` = '" . $this->db->escape($hash) . "'
               AND `purpose` = '" . $this->db->escape($purpose) . "'
               {$where_manager}
               AND `used_at` IS NULL
               AND `expires_at` >= NOW()
             LIMIT 1"
        );
        if (!$query || !$query->num_rows) {
            return false;
        }
        $this->db->query("UPDATE `{$this->prefix}passkey_challenges` SET `used_at` = NOW() WHERE `id` = " . (int)$query->row['id'] . " LIMIT 1");
        return true;
    }

    private function cleanupChallenges(): void {
        $this->db->query("DELETE FROM `{$this->prefix}passkey_challenges` WHERE `expires_at` < DATE_SUB(NOW(), INTERVAL 1 DAY) OR `used_at` < DATE_SUB(NOW(), INTERVAL 1 DAY)");
    }

    private function credentialByRawId(string $credential_id): ?array {
        $query = $this->db->query(
            "SELECT c.`id` AS `credential_record_id`, c.`manager_id`, c.`credential_id_hash`, c.`credential_id`,
                    c.`public_key_cose`, c.`public_key_alg`, c.`sign_count`, c.`transports`, c.`device_label`,
                    c.`created_at`, c.`last_used_at`, c.`revoked_at`,
                    m.`id`, m.`username`, m.`password`, m.`name`, m.`image`, m.`role`, m.`status`
             FROM `{$this->prefix}manager_passkey_credentials` c
             INNER JOIN `{$this->prefix}managers` m ON m.`id` = c.`manager_id`
             WHERE c.`credential_id_hash` = '" . $this->db->escape(hash('sha256', $credential_id)) . "'
               AND c.`revoked_at` IS NULL
               AND m.`status` = 1
             LIMIT 1"
        );
        return $query && $query->num_rows ? $query->row : null;
    }

    private function rpId(): string {
        $host = parse_url($this->origin(), PHP_URL_HOST);
        return strtolower((string)$host);
    }

    private function origin(): string {
        $base = defined('HTTP_SERVER') ? (string)HTTP_SERVER : '';
        $scheme = parse_url($base, PHP_URL_SCHEME);
        $host = parse_url($base, PHP_URL_HOST);
        $port = parse_url($base, PHP_URL_PORT);
        if (!$scheme || !$host) {
            $server = RequestContext::server();
            $https = !empty($server['HTTPS']) && $server['HTTPS'] !== 'off';
            $scheme = $https ? 'https' : 'http';
            $host = (string)($server['HTTP_HOST'] ?? 'localhost');
            $host = preg_replace('/:\d+$/', '', $host);
        }
        return strtolower($scheme . '://' . $host . ($port ? ':' . (int)$port : ''));
    }

    private function isAllowedOrigin(string $origin): bool {
        return rtrim(strtolower($origin), '/') === rtrim($this->origin(), '/');
    }

    private function requestHash(string $key): string {
        $server = RequestContext::server();
        return hash('sha256', (string)($server[$key] ?? ''));
    }

    private function normalizeLabel(string $label): string {
        $label = trim(strip_tags($label));
        if ($label === '') {
            $label = 'Passkey на этом устройстве';
        }
        return mb_substr($label, 0, 128, 'UTF-8');
    }

    private function coseAlgorithm(array $cose): int {
        return isset($cose[3]) ? (int)$cose[3] : 0;
    }

    private function publicKeyPemFromCose(array $cose, int $alg): string {
        if ($alg === -7) {
            $x = $cose[-2] ?? '';
            $y = $cose[-3] ?? '';
            if (($cose[1] ?? null) !== 2 || ($cose[-1] ?? null) !== 1 || strlen($x) !== 32 || strlen($y) !== 32) {
                throw new \RuntimeException('Некорректный EC-ключ passkey.');
            }
            $algo = self::asn1Sequence(self::asn1Oid('1.2.840.10045.2.1') . self::asn1Oid('1.2.840.10045.3.1.7'));
            $spki = self::asn1Sequence($algo . self::asn1BitString("\x04" . $x . $y));
            return self::pem('PUBLIC KEY', $spki);
        }
        if ($alg === -257) {
            $n = $cose[-1] ?? '';
            $e = $cose[-2] ?? '';
            if (($cose[1] ?? null) !== 3 || $n === '' || $e === '') {
                throw new \RuntimeException('Некорректный RSA-ключ passkey.');
            }
            $rsa = self::asn1Sequence(self::asn1Integer($n) . self::asn1Integer($e));
            $algo = self::asn1Sequence(self::asn1Oid('1.2.840.113549.1.1.1') . "\x05\x00");
            return self::pem('PUBLIC KEY', self::asn1Sequence($algo . self::asn1BitString($rsa)));
        }
        throw new \RuntimeException('Неподдерживаемый алгоритм passkey.');
    }

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

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

    public static function decodeCbor(string $data) {
        $offset = 0;
        return self::readCbor($data, $offset);
    }

    public static function encodeCbor($value): string {
        if (is_int($value)) {
            return $value >= 0 ? self::cborHead(0, $value) : self::cborHead(1, -1 - $value);
        }
        if (is_string($value)) {
            return self::cborHead(2, strlen($value)) . $value;
        }
        if (is_array($value)) {
            $is_list = array_keys($value) === range(0, count($value) - 1);
            $out = self::cborHead($is_list ? 4 : 5, count($value));
            foreach ($value as $k => $v) {
                if (!$is_list) {
                    $out .= is_int($k) ? self::encodeCbor($k) : self::cborHead(3, strlen((string)$k)) . (string)$k;
                }
                $out .= self::encodeCbor($v);
            }
            return $out;
        }
        throw new \RuntimeException('Unsupported CBOR value.');
    }

    private static function readCbor(string $data, int &$offset) {
        if ($offset >= strlen($data)) {
            throw new \RuntimeException('Unexpected CBOR end.');
        }
        $initial = ord($data[$offset++]);
        $major = $initial >> 5;
        $additional = $initial & 31;
        $length = self::readCborLength($data, $offset, $additional);

        if ($major === 0) return $length;
        if ($major === 1) return -1 - $length;
        if ($major === 2 || $major === 3) {
            $raw = substr($data, $offset, $length);
            $offset += $length;
            return $raw;
        }
        if ($major === 4) {
            $arr = array();
            for ($i = 0; $i < $length; $i++) $arr[] = self::readCbor($data, $offset);
            return $arr;
        }
        if ($major === 5) {
            $map = array();
            for ($i = 0; $i < $length; $i++) {
                $key = self::readCbor($data, $offset);
                $map[$key] = self::readCbor($data, $offset);
            }
            return $map;
        }
        if ($major === 7) {
            return $additional === 21 ? true : ($additional === 20 ? false : null);
        }
        throw new \RuntimeException('Unsupported CBOR major type.');
    }

    private static function readCborLength(string $data, int &$offset, int $additional): int {
        if ($additional < 24) return $additional;
        if ($additional === 24) return ord($data[$offset++]);
        if ($additional === 25) {
            $value = unpack('n', substr($data, $offset, 2))[1];
            $offset += 2;
            return $value;
        }
        if ($additional === 26) {
            $value = unpack('N', substr($data, $offset, 4))[1];
            $offset += 4;
            return $value;
        }
        throw new \RuntimeException('Unsupported CBOR length.');
    }

    private static function cborHead(int $major, int $length): string {
        if ($length < 24) return chr(($major << 5) | $length);
        if ($length <= 0xff) return chr(($major << 5) | 24) . chr($length);
        if ($length <= 0xffff) return chr(($major << 5) | 25) . pack('n', $length);
        return chr(($major << 5) | 26) . pack('N', $length);
    }

    private static function asn1Length(int $len): string {
        if ($len < 128) return chr($len);
        $bytes = ltrim(pack('N', $len), "\x00");
        return chr(0x80 | strlen($bytes)) . $bytes;
    }

    private static function asn1Sequence(string $body): string {
        return "\x30" . self::asn1Length(strlen($body)) . $body;
    }

    private static function asn1Integer(string $body): string {
        $body = ltrim($body, "\x00");
        if ($body === '' || (ord($body[0]) & 0x80)) {
            $body = "\x00" . $body;
        }
        return "\x02" . self::asn1Length(strlen($body)) . $body;
    }

    private static function asn1BitString(string $body): string {
        return "\x03" . self::asn1Length(strlen($body) + 1) . "\x00" . $body;
    }

    private static function asn1Oid(string $oid): string {
        $parts = array_map('intval', explode('.', $oid));
        $body = chr($parts[0] * 40 + $parts[1]);
        for ($i = 2; $i < count($parts); $i++) {
            $n = $parts[$i];
            $stack = array($n & 0x7f);
            while ($n >>= 7) {
                array_unshift($stack, 0x80 | ($n & 0x7f));
            }
            foreach ($stack as $b) $body .= chr($b);
        }
        return "\x06" . self::asn1Length(strlen($body)) . $body;
    }

    private static function pem(string $label, string $der): string {
        return "-----BEGIN {$label}-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END {$label}-----\n";
    }
}
