<?php

declare(strict_types=1);

namespace Atdt;

/**
 * Browser push for the SysOp (1.6.1, owner request): a caller paging the
 * sysop should reach them wherever they are, not only while the console
 * tab is open. Standard Web Push — VAPID (RFC 8292) signed with ES256,
 * payloads sealed per RFC 8291 (ECDH P-256 + HKDF + AES-128-GCM) — done
 * entirely with ext-openssl, because the release ships no vendor/ and
 * never will (SPEC §21).
 *
 * The board only ever PUSHES; subscriptions are created by the SysOp's
 * own browser in the console (Settings > SysOp notifications) and live
 * in push_subs. No subscriptions, no outside connections — the same
 * opt-in posture as outgoing mail. Sends are best-effort with short
 * timeouts: a page must ring the caller's screen on time whether or not
 * a push service answers. Endpoints that report themselves gone
 * (404/410) are dropped so dead browsers do not pile up.
 */
final class WebPush
{
    /** Seconds a push may sit undelivered at the service: a page rings
     *  for ~90; stale beyond that is stale. */
    private const TTL = 120;
    private const TIMEOUT = 3; // seconds per endpoint, best-effort

    // ---- capability -------------------------------------------------------

    /** The pieces this build of PHP must have before anything subscribes. */
    public static function supported(): bool
    {
        return function_exists('openssl_pkey_derive')
            && function_exists('hash_hkdf')
            && in_array('aes-128-gcm', array_map('strtolower', openssl_get_cipher_methods()), true);
    }

    // ---- VAPID keys -------------------------------------------------------

    /**
     * The board's VAPID public key (base64url, uncompressed P-256 point),
     * minting the pair on first use. The private key stays in settings —
     * the same trust level as the SMTP password.
     */
    public static function publicKey(Settings $settings): ?string
    {
        $pub = (string)($settings->get('push_vapid_pub', '') ?? '');
        if ($pub !== '') {
            return $pub;
        }
        $key = openssl_pkey_new([
            'curve_name' => 'prime256v1',
            'private_key_type' => OPENSSL_KEYTYPE_EC,
        ]);
        if ($key === false) {
            return null;
        }
        $det = openssl_pkey_get_details($key);
        if ($det === false || !isset($det['ec']['x'], $det['ec']['y'])) {
            return null;
        }
        $pem = '';
        if (!openssl_pkey_export($key, $pem)) {
            return null;
        }
        $pub = self::b64url("\x04" . self::pad32((string)$det['ec']['x']) . self::pad32((string)$det['ec']['y']));
        $settings->set('push_vapid_pem', $pem);
        $settings->set('push_vapid_pub', $pub);
        return $pub;
    }

    // ---- subscriptions ----------------------------------------------------

    /** Store (or refresh) a browser's subscription. */
    public static function subscribe(Db $db, string $endpoint, string $p256dh, string $auth, string $label): bool
    {
        if (!preg_match('#^https://#i', $endpoint) || strlen($endpoint) > 2000
            || $p256dh === '' || $auth === ''
            || strlen(self::b64urlDecode($p256dh)) !== 65 || strlen(self::b64urlDecode($auth)) < 16
        ) {
            return false;
        }
        $hash = hash('sha256', $endpoint);
        $label = mb_strimwidth(trim(preg_replace('/[\x00-\x1f\x7f]/', '', $label) ?? ''), 0, 60);
        $row = [
            'endpoint_hash' => $hash, 'endpoint' => $endpoint,
            'p256dh' => $p256dh, 'auth' => $auth,
            'label' => $label === '' ? 'a browser' : $label,
            'created_at' => now_utc(),
        ];
        // Native upsert (1.7.4); created_at stays the FIRST subscribe's
        // stamp, so the refresh path never rewrites history.
        $db->upsert('push_subs', $row, ['endpoint_hash'], ['endpoint', 'p256dh', 'auth', 'label']);
        return true;
    }

    /** @return list<array<string, mixed>> */
    public static function subscriptions(Db $db): array
    {
        return $db->all('SELECT * FROM push_subs ORDER BY id');
    }

    public static function remove(Db $db, int $id): void
    {
        $db->q('DELETE FROM push_subs WHERE id = ?', [$id]);
    }

    public static function removeByEndpoint(Db $db, string $endpoint): void
    {
        $db->q('DELETE FROM push_subs WHERE endpoint_hash = ?', [hash('sha256', $endpoint)]);
    }

    // ---- sending ----------------------------------------------------------

    /**
     * Ring every subscribed browser. Best-effort by design: failures are
     * swallowed (a slow push service must never slow a caller), gone
     * endpoints are pruned. Returns [delivered, dropped].
     * @return array{0: int, 1: int}
     */
    public static function notify(Db $db, Settings $settings, string $title, string $body, string $url = ''): array
    {
        $subs = self::subscriptions($db);
        if ($subs === [] || !self::supported()) {
            return [0, 0];
        }
        $pem = (string)($settings->get('push_vapid_pem', '') ?? '');
        $pub = (string)($settings->get('push_vapid_pub', '') ?? '');
        if ($pem === '' || $pub === '') {
            return [0, 0];
        }
        $payload = (string)json_encode([
            'title' => mb_strimwidth($title, 0, 60),
            'body' => mb_strimwidth($body, 0, 160),
            'url' => $url,
            'tag' => 'atdt-page',
        ], JSON_UNESCAPED_UNICODE);

        $sent = 0;
        $dropped = 0;
        foreach ($subs as $sub) {
            try {
                $status = self::send($settings, $pem, $pub, $sub, $payload);
            } catch (\Throwable $e) {
                error_log('[atdt:push] ' . $e->getMessage());
                continue;
            }
            if ($status === 404 || $status === 410) {
                // The browser unsubscribed or is gone for good.
                self::remove($db, (int)$sub['id']);
                $dropped++;
            } elseif ($status >= 200 && $status < 300) {
                $sent++;
            }
        }
        return [$sent, $dropped];
    }

    /** @param array<string, mixed> $sub @return int HTTP status (0 = no answer) */
    private static function send(Settings $settings, string $pem, string $pub, array $sub, string $payload): int
    {
        $endpoint = (string)$sub['endpoint'];
        $parts = parse_url($endpoint);
        if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || ($parts['host'] ?? '') === '') {
            return 410; // malformed row: treat as gone
        }
        $aud = 'https://' . $parts['host'] . (isset($parts['port']) ? ':' . (int)$parts['port'] : '');
        $jwt = self::vapidJwt($settings, $pem, $aud);
        $sealed = self::seal(
            self::b64urlDecode((string)$sub['p256dh']),
            self::b64urlDecode((string)$sub['auth']),
            $payload
        );
        $headers = [
            'Content-Type: application/octet-stream',
            'Content-Encoding: aes128gcm',
            'Content-Length: ' . strlen($sealed),
            'TTL: ' . self::TTL,
            'Urgency: high',
            'Authorization: vapid t=' . $jwt . ', k=' . $pub,
        ];
        return self::post($endpoint, $headers, $sealed);
    }

    /** RFC 8292 §2: a short-lived ES256 token aimed at the push service. */
    private static function vapidJwt(Settings $settings, string $pem, string $aud): string
    {
        $from = trim((string)($settings->get('mail_from', '') ?? ''));
        $base = trim((string)($settings->get('base_url', '') ?? ''));
        $sub = $from !== '' && Mailer::plausible($from)
            ? 'mailto:' . $from
            : (preg_match('#^https://#i', $base) === 1 ? $base : 'mailto:sysop@invalid');
        $seg = self::b64url((string)json_encode(['typ' => 'JWT', 'alg' => 'ES256']))
            . '.' . self::b64url((string)json_encode(['aud' => $aud, 'exp' => time() + 12 * 3600, 'sub' => $sub]));
        $der = '';
        if (!openssl_sign($seg, $der, $pem, OPENSSL_ALGO_SHA256)) {
            throw new \RuntimeException('VAPID signing failed');
        }
        return $seg . '.' . self::b64url(self::derSigToRaw($der));
    }

    /**
     * RFC 8291: seal the payload for one browser. ECDH against the
     * subscription's P-256 key, two HKDF steps, AES-128-GCM, and the
     * aes128gcm binary header so the record explains itself.
     */
    private static function seal(string $uaPub, string $authSecret, string $payload): string
    {
        if (strlen($uaPub) !== 65 || $uaPub[0] !== "\x04") {
            throw new \RuntimeException('bad p256dh key');
        }
        $eph = openssl_pkey_new([
            'curve_name' => 'prime256v1',
            'private_key_type' => OPENSSL_KEYTYPE_EC,
        ]);
        if ($eph === false) {
            throw new \RuntimeException('ephemeral key generation failed');
        }
        $det = openssl_pkey_get_details($eph);
        if ($det === false) {
            throw new \RuntimeException('ephemeral key unreadable');
        }
        $asPub = "\x04" . self::pad32((string)$det['ec']['x']) . self::pad32((string)$det['ec']['y']);
        $peer = openssl_pkey_get_public(self::rawPointToPem($uaPub));
        if ($peer === false) {
            throw new \RuntimeException('subscription key unreadable');
        }
        $shared = openssl_pkey_derive($peer, $eph, 32);
        if (!is_string($shared) || $shared === '') {
            throw new \RuntimeException('ECDH failed');
        }
        // RFC 8291 §3.3–3.4: combine with the auth secret, then the
        // content-encryption key and nonce per RFC 8188.
        $ikm = hash_hkdf('sha256', $shared, 32, "WebPush: info\x00" . $uaPub . $asPub, $authSecret);
        $salt = random_bytes(16);
        $cek = hash_hkdf('sha256', $ikm, 16, "Content-Encoding: aes128gcm\x00", $salt);
        $nonce = hash_hkdf('sha256', $ikm, 12, "Content-Encoding: nonce\x00", $salt);
        $tag = '';
        $cipher = openssl_encrypt($payload . "\x02", 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $tag);
        if ($cipher === false) {
            throw new \RuntimeException('payload encryption failed');
        }
        // Header: salt, record size, keyid length, our public key.
        return $salt . pack('N', 4096) . chr(65) . $asPub . $cipher . $tag;
    }

    /** POST to the push service: curl when the host has it, streams when
     *  not, a short leash either way. */
    private static function post(string $endpoint, array $headers, string $body): int
    {
        if (function_exists('curl_init')) {
            $ch = curl_init($endpoint);
            if ($ch === false) {
                return 0;
            }
            curl_setopt_array($ch, [
                CURLOPT_POST => true,
                CURLOPT_HTTPHEADER => $headers,
                CURLOPT_POSTFIELDS => $body,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => self::TIMEOUT,
                CURLOPT_TIMEOUT => self::TIMEOUT,
            ]);
            curl_exec($ch);
            $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            curl_close($ch);
            return $status;
        }
        $ctx = stream_context_create(['http' => [
            'method' => 'POST',
            'header' => implode("\r\n", $headers),
            'content' => $body,
            'timeout' => self::TIMEOUT,
            'ignore_errors' => true,
        ]]);
        $http_response_header = [];
        @file_get_contents($endpoint, false, $ctx);
        foreach ($http_response_header as $h) {
            if (preg_match('#^HTTP/\S+\s+(\d+)#', (string)$h, $m) === 1) {
                return (int)$m[1];
            }
        }
        return 0;
    }

    // ---- byte plumbing ----------------------------------------------------

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

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

    /** EC coordinates arrive minimally encoded; the wire wants 32 bytes. */
    private static function pad32(string $bin): string
    {
        return str_pad($bin, 32, "\x00", STR_PAD_LEFT);
    }

    /** An uncompressed P-256 point as PEM SubjectPublicKeyInfo. */
    private static function rawPointToPem(string $point): string
    {
        $der = hex2bin('3059301306072a8648ce3d020106082a8648ce3d030107034200') . $point;
        return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n";
    }

    /** OpenSSL emits DER ECDSA signatures; JOSE wants raw r||s (64 bytes). */
    private static function derSigToRaw(string $der): string
    {
        $off = 2;
        if (ord($der[1]) > 0x80) {
            $off += ord($der[1]) - 0x80; // long-form sequence length
        }
        $out = '';
        for ($i = 0; $i < 2; $i++) {
            if (($der[$off] ?? '') !== "\x02") {
                throw new \RuntimeException('unexpected ECDSA signature shape');
            }
            $len = ord($der[$off + 1]);
            $int = substr($der, $off + 2, $len);
            $off += 2 + $len;
            $int = ltrim($int, "\x00");
            $out .= self::pad32($int);
        }
        return $out;
    }
}
