<?php
/**
 * PpWebPush — self-contained Web Push sender (VAPID + RFC 8291 aes128gcm payload
 * encryption) in pure PHP (openssl + hash_hkdf). No Composer, no dependencies.
 *
 * Global class (no namespace) so the identical file can be emitted into generated
 * apps and `require`d anywhere. Used by:
 *   • the console (MobileController) → PpWebPush::generateVapidKeys()
 *   • generated apps → new PpWebPush($pub,$priv,$subject); $p->sendAll($subs,$payload)
 *
 * Refs: RFC 8291 (Message Encryption for Web Push), RFC 8188 (aes128gcm),
 *       RFC 8292 (VAPID). Curve P-256; JOSE ES256.
 */
class PpWebPush
{
    private string $pub;      // VAPID public  key — base64url, 65-byte uncompressed point
    private string $priv;     // VAPID private key — base64url, 32-byte scalar
    private string $subject;  // "mailto:you@example.com" or an https URL

    public function __construct(string $vapidPublic, string $vapidPrivate, string $subject = '')
    {
        $this->pub = trim($vapidPublic);
        $this->priv = trim($vapidPrivate);
        $s = trim($subject);
        $this->subject = ($s !== '') ? $s : 'mailto:admin@example.com';
    }

    /* ─────────── base64url ─────────── */
    public static function b64u(string $bin): string { return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); }
    public static function unb64u(string $s): string { return (string) base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4)); }

    /* ─────────── VAPID key generation (P-256) ─────────── */
    public static function generateVapidKeys(): array
    {
        $k = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
        if (!$k) throw new \RuntimeException('openssl EC keygen failed');
        $d = openssl_pkey_get_details($k);
        $point = "\x04" . self::pad32($d['ec']['x']) . self::pad32($d['ec']['y']);
        return [
            'publicKey'  => self::b64u($point),
            'privateKey' => self::b64u(self::pad32($d['ec']['d'])),
        ];
    }

    private static function pad32(string $b): string { return str_pad($b, 32, "\x00", STR_PAD_LEFT); }

    /* ─────────── raw P-256 material → PEM (fixed DER templates) ─────────── */
    private static function pubPem(string $point65): string
    {
        $der = hex2bin('3059301306072a8648ce3d020106082a8648ce3d030107034200') . $point65;
        return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n";
    }
    private static function privPem(string $d32, string $point65): string
    {
        $der = hex2bin('308187020100301306072a8648ce3d020106082a8648ce3d030107046d306b0201010420')
            . $d32 . hex2bin('a144034200') . $point65;
        return "-----BEGIN PRIVATE KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PRIVATE KEY-----\n";
    }

    /* ─────────── ECDSA DER signature → raw R||S (64 bytes) for JOSE ─────────── */
    private static function derToRaw(string $der): string
    {
        $off = 0;
        if (ord($der[$off++]) !== 0x30) throw new \RuntimeException('bad ECDSA DER');
        $len = ord($der[$off++]); if ($len & 0x80) $off += ($len & 0x7f);
        $readInt = static function () use (&$off, $der): string {
            if (ord($der[$off++]) !== 0x02) throw new \RuntimeException('bad ECDSA int');
            $l = ord($der[$off++]); $v = substr($der, $off, $l); $off += $l;
            return str_pad(ltrim($v, "\x00"), 32, "\x00", STR_PAD_LEFT);
        };
        return $readInt() . $readInt();
    }

    /* ─────────── VAPID Authorization header for an endpoint ─────────── */
    private function vapidAuth(string $endpoint): string
    {
        $u = parse_url($endpoint);
        if (empty($u['scheme']) || empty($u['host'])) throw new \RuntimeException('bad endpoint');
        $aud = $u['scheme'] . '://' . $u['host'] . (isset($u['port']) ? ':' . $u['port'] : '');
        $head = self::b64u((string) json_encode(['typ' => 'JWT', 'alg' => 'ES256']));
        $body = self::b64u((string) json_encode(['aud' => $aud, 'exp' => time() + 43200, 'sub' => $this->subject]));
        $input = $head . '.' . $body;
        $key = openssl_pkey_get_private(self::privPem(self::unb64u($this->priv), self::unb64u($this->pub)));
        if (!$key) throw new \RuntimeException('invalid VAPID private key');
        $der = '';
        if (!openssl_sign($input, $der, $key, OPENSSL_ALGO_SHA256)) throw new \RuntimeException('VAPID sign failed');
        $jwt = $input . '.' . self::b64u(self::derToRaw($der));
        return 'Authorization: vapid t=' . $jwt . ', k=' . $this->pub;
    }

    /* ─────────── RFC 8291 payload encryption (aes128gcm) ─────────── */
    private function encrypt(string $plaintext, string $p256dh, string $auth): string
    {
        $uaPublic   = self::unb64u($p256dh);   // client public key (65 bytes)
        $authSecret = self::unb64u($auth);     // client auth secret (16 bytes)
        if (strlen($uaPublic) !== 65 || strlen($authSecret) < 16) throw new \RuntimeException('bad subscription keys');

        $ek = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
        $ed = openssl_pkey_get_details($ek);
        $asPublic = "\x04" . self::pad32($ed['ec']['x']) . self::pad32($ed['ec']['y']);   // server ephemeral public (65)

        $uaKey = openssl_pkey_get_public(self::pubPem($uaPublic));
        if (!$uaKey) throw new \RuntimeException('bad p256dh');
        $ecdh = openssl_pkey_derive($uaKey, $ek);   // P-256 shared secret is 32 bytes
        if ($ecdh === false || strlen($ecdh) < 32) throw new \RuntimeException('ECDH failed');
        $ecdh = substr($ecdh, 0, 32);

        $keyInfo = "WebPush: info\x00" . $uaPublic . $asPublic;
        $ikm   = hash_hkdf('sha256', $ecdh, 32, $keyInfo, $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($plaintext . "\x02", 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
        if ($cipher === false) throw new \RuntimeException('aes128gcm failed');

        // aes128gcm header (RFC 8188): salt(16) | rs(uint32) | idlen(1) | keyid(as_public) | ciphertext | tag
        return $salt . pack('N', 4096) . chr(65) . $asPublic . $cipher . $tag;
    }

    /* ─────────── send to one subscription ─────────── */
    public function send(array $sub, array $payload, int $ttl = 2419200): array
    {
        $endpoint = (string) ($sub['endpoint'] ?? '');
        $p256dh   = (string) ($sub['keys']['p256dh'] ?? ($sub['p256dh'] ?? ''));
        $auth     = (string) ($sub['keys']['auth'] ?? ($sub['auth'] ?? ''));
        if ($endpoint === '' || $p256dh === '' || $auth === '') return ['success' => false, 'status' => 0, 'error' => 'incomplete subscription'];
        if ($this->pub === '' || $this->priv === '') return ['success' => false, 'status' => 0, 'error' => 'VAPID keys not configured'];
        try {
            $body = $this->encrypt((string) json_encode($payload), $p256dh, $auth);
            $headers = [
                $this->vapidAuth($endpoint),
                'Content-Type: application/octet-stream',
                'Content-Encoding: aes128gcm',
                'TTL: ' . max(0, $ttl),
            ];
        } catch (\Throwable $e) {
            return ['success' => false, 'status' => 0, 'error' => $e->getMessage()];
        }
        $ch = curl_init($endpoint);
        curl_setopt_array($ch, [
            CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false, CURLOPT_TIMEOUT => 20, CURLOPT_CONNECTTIMEOUT => 10,
        ]);
        $resp = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $cerr = curl_error($ch);
        // curl_close() is a no-op / deprecated on PHP 8.5+; the handle frees on scope exit.
        // 201 Created = accepted; 404/410 = subscription gone (prune it).
        return [
            'success' => ($status >= 200 && $status < 300),
            'status'  => $status,
            'expired' => in_array($status, [404, 410], true),
            'error'   => $cerr !== '' ? $cerr : ($status >= 300 ? trim((string) $resp) : ''),
        ];
    }

    /* ─────────── send to many; returns counts + endpoints to prune ─────────── */
    public function sendAll(array $subs, array $payload): array
    {
        $sent = 0; $failed = 0; $expired = [];
        foreach ($subs as $key => $row) {
            $s = isset($row['sub']) && is_array($row['sub']) ? $row['sub'] : $row;
            if (!is_array($s)) continue;
            $r = $this->send($s, $payload);
            if ($r['success']) { $sent++; }
            else { $failed++; if (!empty($r['expired'])) $expired[] = $key; }
        }
        return ['sent' => $sent, 'failed' => $failed, 'expired' => $expired];
    }
}
