<?php
/* ============================================================================
 *  config/webpush.php — self-contained Web Push (VAPID + aes128gcm) sender.
 *  No Composer needed; uses openssl + hash_hkdf (PHP 7.3+).
 *  webpush_send($subscription, $payloadJson) returns ['ok'=>bool,'code'=>int,'gone'=>bool].
 * ==========================================================================*/

/* ── VAPID keys (generated for 7d-lao.com). Public key is also used by the
      browser; private key must stay on the server. ── */
if (!defined('VAPID_PUBLIC'))  define('VAPID_PUBLIC',  'BKq9Z5pac_iEehZd3aWwsNWVzKozJd2SH8ZUNv28AaW9BIt4hmiAwdKQmoPZhgntV-V8IHwa8SsKI0JiHdOSk4k');
if (!defined('VAPID_PRIVATE')) define('VAPID_PRIVATE', 'z0419S8RYV0FeFTzsWqsMYDkfm5gwuYuGq2mQipDHV8');
if (!defined('VAPID_SUBJECT')) define('VAPID_SUBJECT', 'mailto:admin@7d-lao.com');

function wp_b64u_encode(string $b): string { return rtrim(strtr(base64_encode($b), '+/', '-_'), '='); }
function wp_b64u_decode(string $s): string { return base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4)); }

/* Convert a DER ECDSA signature to raw r||s (64 bytes for P-256). */
function wp_der_to_raw(string $der): string {
    $off = 0;
    if (ord($der[$off++]) !== 0x30) return $der;
    if (ord($der[$off]) & 0x80) $off += 1 + (ord($der[$off]) & 0x7f); else $off++;
    $readInt = function() use (&$off, $der) {
        $off++; // 0x02
        $len = ord($der[$off++]);
        $v = substr($der, $off, $len); $off += $len;
        $v = ltrim($v, "\x00");
        return str_pad($v, 32, "\x00", STR_PAD_LEFT);
    };
    return $readInt() . $readInt();
}

/* Build a PEM EC private key (SEC1) from raw 32-byte scalar + 65-byte public point. */
function wp_vapid_private_pem(): string {
    $d   = wp_b64u_decode(VAPID_PRIVATE);
    $pub = wp_b64u_decode(VAPID_PUBLIC);
    $d   = str_pad($d, 32, "\x00", STR_PAD_LEFT);
    $oid = "\xA0\x0A\x06\x08" . hex2bin('2a8648ce3d030107');         // [0] namedCurve prime256v1
    $pk  = "\xA1\x44\x03\x42\x00" . $pub;                            // [1] public BIT STRING
    $body = "\x02\x01\x01" . "\x04\x20" . $d . $oid . $pk;           // version + privateKey + params + pub
    $der  = "\x30" . chr(strlen($body)) . $body;
    return "-----BEGIN EC PRIVATE KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END EC PRIVATE KEY-----\n";
}

/* Build a PEM public key from a raw 65-byte uncompressed point. */
function wp_public_pem_from_point(string $point): string {
    $der = hex2bin('3059301306072a8648ce3d020106082a8648ce3d030107034200') . $point;
    return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n";
}

/* VAPID Authorization header value for a given endpoint origin. */
function wp_vapid_header(string $origin): ?string {
    $jwtHeader = wp_b64u_encode(json_encode(['typ'=>'JWT','alg'=>'ES256']));
    $jwtClaims = wp_b64u_encode(json_encode(['aud'=>$origin,'exp'=>time()+43200,'sub'=>VAPID_SUBJECT]));
    $input = $jwtHeader . '.' . $jwtClaims;
    $pk = openssl_pkey_get_private(wp_vapid_private_pem());
    if (!$pk) return null;
    $sig = '';
    if (!openssl_sign($input, $sig, $pk, OPENSSL_ALGO_SHA256)) return null;
    $jwt = $input . '.' . wp_b64u_encode(wp_der_to_raw($sig));
    return 'vapid t=' . $jwt . ', k=' . VAPID_PUBLIC;
}

/**
 * Send one push message.
 * @param array  $sub  ['endpoint'=>..,'p256dh'=>..,'auth'=>..]
 * @param string $payload  JSON string (will be encrypted)
 */
function webpush_send(array $sub, string $payload): array {
    $endpoint = $sub['endpoint'] ?? '';
    if ($endpoint === '' || !function_exists('openssl_pkey_derive')) return ['ok'=>false,'code'=>0,'gone'=>false];

    $uaPublic = wp_b64u_decode($sub['p256dh']);   // 65 bytes
    $auth     = wp_b64u_decode($sub['auth']);     // 16 bytes
    if (strlen($uaPublic) !== 65 || strlen($auth) < 16) return ['ok'=>false,'code'=>0,'gone'=>false];

    /* ephemeral (application server) key pair */
    $ek = openssl_pkey_new(['curve_name'=>'prime256v1','private_key_type'=>OPENSSL_KEYTYPE_EC]);
    if (!$ek) return ['ok'=>false,'code'=>0,'gone'=>false];
    $det = openssl_pkey_get_details($ek);
    $asPublic = "\x04" . str_pad($det['ec']['x'],32,"\x00",STR_PAD_LEFT) . str_pad($det['ec']['y'],32,"\x00",STR_PAD_LEFT);

    /* ECDH shared secret */
    $uaKey = openssl_pkey_get_public(wp_public_pem_from_point($uaPublic));
    if (!$uaKey) return ['ok'=>false,'code'=>0,'gone'=>false];
    $shared = openssl_pkey_derive($uaKey, $ek, 32);
    if ($shared === false) return ['ok'=>false,'code'=>0,'gone'=>false];

    /* RFC 8291 key derivation */
    $salt = random_bytes(16);
    $ikm  = hash_hkdf('sha256', $shared, 32, "WebPush: info\x00" . $uaPublic . $asPublic, $auth);
    $cek  = hash_hkdf('sha256', $ikm, 16, "Content-Encoding: aes128gcm\x00", $salt);
    $nonce= hash_hkdf('sha256', $ikm, 12, "Content-Encoding: nonce\x00", $salt);

    /* encrypt (single record, 0x02 = last-record delimiter) */
    $tag = '';
    $cipher = openssl_encrypt($payload . "\x02", 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
    if ($cipher === false) return ['ok'=>false,'code'=>0,'gone'=>false];

    /* aes128gcm content-coding header: salt(16) | rs(4) | idlen(1) | keyid(as_public 65) */
    $header = $salt . pack('N', 4096) . chr(65) . $asPublic;
    $body   = $header . $cipher . $tag;

    $parts  = parse_url($endpoint);
    $origin = $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port']) ? ':'.$parts['port'] : '');
    $auths  = wp_vapid_header($origin);
    if (!$auths) return ['ok'=>false,'code'=>0,'gone'=>false];

    $ch = curl_init($endpoint);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 8,
        CURLOPT_HTTPHEADER => [
            'Authorization: ' . $auths,
            'Content-Type: application/octet-stream',
            'Content-Encoding: aes128gcm',
            'TTL: 86400',
        ],
        CURLOPT_POSTFIELDS => $body,
    ]);
    curl_exec($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    return ['ok' => ($code >= 200 && $code < 300), 'code' => $code, 'gone' => in_array($code, [404, 410], true)];
}