<?php

if (!defined('ABSPATH')) {
    exit;
}

class AIEO_Push_Sender {

    
    const OPTION_VAPID = 'aieo_push_vapid';

    
    private static $instance = null;

    public static function instance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {}

    
    
    

    
    public function get_keys() {
        $stored = get_option(self::OPTION_VAPID, array());
        if (is_array($stored) && !empty($stored['private_pem']) && !empty($stored['public_b64url'])) {
            
            if (empty($stored['subject'])) {
                $stored['subject'] = self::default_subject();
            }
            return $stored;
        }
        return $this->generate_and_store_keys();
    }

    
    public function generate_and_store_keys() {
        if (!function_exists('openssl_pkey_new')) {
            throw new \RuntimeException('OpenSSL is required for VAPID key generation.');
        }

        
        
        
        
        
        
        
        
        
        
        
        $is_windows  = (PHP_OS_FAMILY === 'Windows') || (DIRECTORY_SEPARATOR === '\\');
        $shipped_cnf = __DIR__ . DIRECTORY_SEPARATOR . 'openssl.cnf';
        $shipped_cnf_norm = ($is_windows && file_exists($shipped_cnf))
            ? str_replace('\\', '/', $shipped_cnf)
            : '';

        $original_env = getenv('OPENSSL_CONF');
        if ($shipped_cnf_norm !== '') {
            
            putenv('OPENSSL_CONF=' . $shipped_cnf_norm);
        }

        $config = array(
            'curve_name'       => 'prime256v1',
            'private_key_type' => OPENSSL_KEYTYPE_EC,
        );
        if ($shipped_cnf_norm !== '') {
            $config['config'] = $shipped_cnf_norm;
        }

        $res = openssl_pkey_new($config);

        
        if ($shipped_cnf_norm !== '') {
            
            putenv($original_env === false ? 'OPENSSL_CONF' : 'OPENSSL_CONF=' . $original_env);
        }
        if ($res === false) {
            
            
            $errors = array();
            while ($e = openssl_error_string()) { $errors[] = $e; }
            
            throw new \RuntimeException('openssl_pkey_new failed: ' . implode(' | ', $errors));
        }

        $private_pem = '';
        
        
        $export_config = array();
        if (isset($config['config'])) { $export_config['config'] = $config['config']; }
        if (!openssl_pkey_export($res, $private_pem, null, $export_config)) {
            $errors = array();
            while ($e = openssl_error_string()) { $errors[] = $e; }
            
            throw new \RuntimeException('openssl_pkey_export failed: ' . implode(' | ', $errors));
        }
        $details = openssl_pkey_get_details($res);
        if (!isset($details['ec']['x'], $details['ec']['y'])) {
            throw new \RuntimeException('Could not read EC public key details.');
        }

        
        
        
        $x = self::lpad($details['ec']['x'], 32);
        $y = self::lpad($details['ec']['y'], 32);
        $public_raw = "\x04" . $x . $y;
        $public_b64 = self::b64url($public_raw);

        $payload = array(
            'private_pem'   => $private_pem,
            'public_b64url' => $public_b64,
            'subject'       => self::default_subject(),
            'generated_at'  => time(),
        );
        update_option(self::OPTION_VAPID, $payload, false);
        return $payload;
    }

    
    public static function default_subject() {
        $admin_email = (string) get_option('admin_email', '');
        if ($admin_email !== '' && is_email($admin_email)) {
            return 'mailto:' . $admin_email;
        }
        return (string) home_url('/');
    }

    
    
    

    
    public function send_empty($subscription, $ttl = 0) {
        
        
        
        
        if (class_exists('AIEO_DMM_Web_Push') && !\AIEO_DMM_Web_Push::is_engine_live()) {
            return array('ok' => false, 'status' => 0, 'error' => 'engine_paused');
        }
        if (empty($subscription['endpoint'])) {
            return array('ok' => false, 'status' => 0, 'error' => 'missing-endpoint');
        }
        try {
            $keys    = $this->get_keys();
            $auth_hdr = $this->vapid_authorization_header($subscription['endpoint'], $keys);
        } catch (\Throwable $e) {
            return array('ok' => false, 'status' => 0, 'error' => 'vapid-' . $e->getMessage());
        }

        $resp = wp_remote_post($subscription['endpoint'], array(
            'timeout'  => 10,
            'headers'  => array(
                'TTL'           => (string) max(0, (int) $ttl),
                'Content-Length' => '0',
                'Authorization' => $auth_hdr,
            ),
            'body'     => '',
        ));
        if (is_wp_error($resp)) {
            return array('ok' => false, 'status' => 0, 'error' => $resp->get_error_message());
        }
        $status = (int) wp_remote_retrieve_response_code($resp);
        
        
        if ($status >= 200 && $status < 300) {
            return array('ok' => true, 'status' => $status, 'error' => '');
        }
        return array(
            'ok'     => false,
            'status' => $status,
            'error'  => trim((string) wp_remote_retrieve_body($resp)),
        );
    }

    
    public function send($subscription, $payload, $ttl = 0) {
        if (class_exists('AIEO_DMM_Web_Push') && !\AIEO_DMM_Web_Push::is_engine_live()) {
            return array('ok' => false, 'status' => 0, 'error' => 'engine_paused');
        }
        if (empty($subscription['endpoint'])) {
            return array('ok' => false, 'status' => 0, 'error' => 'missing-endpoint');
        }
        if (empty($subscription['p256dh']) || empty($subscription['auth_secret'])) {
            return array('ok' => false, 'status' => 0, 'error' => 'missing-keys');
        }
        if (!function_exists('openssl_pkey_derive') || !function_exists('hash_hkdf')) {
            return array('ok' => false, 'status' => 0, 'error' => 'crypto-unavailable');
        }
        try {
            $keys     = $this->get_keys();
            $auth_hdr = $this->vapid_authorization_header($subscription['endpoint'], $keys);
            $body     = $this->encrypt_payload_aes128gcm(
                (string) $payload,
                self::b64url_decode((string) $subscription['p256dh']),
                self::b64url_decode((string) $subscription['auth_secret'])
            );
        } catch (\Throwable $e) {
            return array('ok' => false, 'status' => 0, 'error' => 'encrypt-' . $e->getMessage());
        }

        $resp = wp_remote_post($subscription['endpoint'], array(
            'timeout' => 10,
            'headers' => array(
                'TTL'              => (string) max(0, (int) $ttl),
                'Content-Type'     => 'application/octet-stream',
                'Content-Encoding' => 'aes128gcm',
                'Content-Length'   => (string) strlen($body),
                'Authorization'    => $auth_hdr,
            ),
            'body' => $body,
        ));
        if (is_wp_error($resp)) {
            return array('ok' => false, 'status' => 0, 'error' => $resp->get_error_message());
        }
        $status = (int) wp_remote_retrieve_response_code($resp);
        if ($status >= 200 && $status < 300) {
            return array('ok' => true, 'status' => $status, 'error' => '');
        }
        return array(
            'ok'     => false,
            'status' => $status,
            'error'  => trim((string) wp_remote_retrieve_body($resp)),
        );
    }

    
    private function encrypt_payload_aes128gcm($payload, $ua_pub_raw, $auth_secret) {
        if (strlen($ua_pub_raw) !== 65 || ord($ua_pub_raw[0]) !== 0x04) {
            throw new \RuntimeException('Invalid p256dh — expected uncompressed P-256 point (65B starting 0x04).');
        }
        if (strlen($auth_secret) !== 16) {
            throw new \RuntimeException('Invalid auth_secret — expected 16 bytes.');
        }
        if (strlen($payload) > 3993) {
            
            throw new \RuntimeException('Payload too large (max 3993 bytes after framing).');
        }

        
        
        
        $is_windows  = (PHP_OS_FAMILY === 'Windows') || (DIRECTORY_SEPARATOR === '\\');
        $shipped_cnf = __DIR__ . DIRECTORY_SEPARATOR . 'openssl.cnf';
        $cnf_norm    = ($is_windows && file_exists($shipped_cnf))
            ? str_replace('\\', '/', $shipped_cnf)
            : '';
        $orig_env    = getenv('OPENSSL_CONF');
        if ($cnf_norm !== '') { putenv('OPENSSL_CONF=' . $cnf_norm); }
        $kcfg = array('curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC);
        if ($cnf_norm !== '') { $kcfg['config'] = $cnf_norm; }
        $as_res = openssl_pkey_new($kcfg);
        if ($cnf_norm !== '') { putenv($orig_env === false ? 'OPENSSL_CONF' : 'OPENSSL_CONF=' . $orig_env); }
        if ($as_res === false) {
            throw new \RuntimeException('openssl_pkey_new (ephemeral) failed.');
        }
        $as_details = openssl_pkey_get_details($as_res);
        if (!isset($as_details['ec']['x'], $as_details['ec']['y'])) {
            throw new \RuntimeException('Could not read ephemeral EC public key.');
        }
        $as_pub_raw = "\x04" . self::lpad($as_details['ec']['x'], 32) . self::lpad($as_details['ec']['y'], 32);

        
        
        $ua_pem = self::raw_p256_to_pem($ua_pub_raw);
        $shared = openssl_pkey_derive($ua_pem, $as_res, 32);
        if ($shared === false) {
            throw new \RuntimeException('openssl_pkey_derive (ECDH) failed.');
        }

        
        $info_key = "WebPush: info\x00" . $ua_pub_raw . $as_pub_raw;
        $prk_key  = hash_hkdf('sha256', $shared, 32, $info_key, $auth_secret);

        
        $salt  = random_bytes(16);
        $cek   = hash_hkdf('sha256', $prk_key, 16, "Content-Encoding: aes128gcm\x00", $salt);
        $nonce = hash_hkdf('sha256', $prk_key, 12, "Content-Encoding: nonce\x00",     $salt);

        
        $plaintext = $payload . "\x02";

        
        $tag = '';
        $ciphertext = openssl_encrypt($plaintext, 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
        if ($ciphertext === false) {
            throw new \RuntimeException('openssl_encrypt (aes-128-gcm) failed.');
        }

        
        $rs     = 4096;
        $idlen  = 65;
        $header = $salt . pack('N', $rs) . chr($idlen) . $as_pub_raw;
        return $header . $ciphertext . $tag;
    }

    
    private static function raw_p256_to_pem($raw65) {
        
        
        
        $prefix = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01"
                . "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42\x00";
        $der = $prefix . $raw65;
        $pem = "-----BEGIN PUBLIC KEY-----\n"
             . chunk_split(base64_encode($der), 64, "\n")
             . "-----END PUBLIC KEY-----\n";
        return $pem;
    }

    
    
    

    
    private function vapid_authorization_header($endpoint, array $keys) {
        $aud = $this->endpoint_origin($endpoint);
        $exp = time() + 12 * HOUR_IN_SECONDS; 

        $header_b64  = self::b64url(wp_json_encode(array('typ' => 'JWT', 'alg' => 'ES256')));
        $payload_b64 = self::b64url(wp_json_encode(array(
            'aud' => $aud,
            'exp' => $exp,
            'sub' => (string) $keys['subject'],
        )));
        $signing_input = $header_b64 . '.' . $payload_b64;

        
        
        $der_sig = '';
        if (!openssl_sign($signing_input, $der_sig, $keys['private_pem'], OPENSSL_ALGO_SHA256)) {
            throw new \RuntimeException('openssl_sign failed for VAPID JWT.');
        }
        $jose_sig = self::ecdsa_der_to_jose($der_sig);
        $jwt = $signing_input . '.' . self::b64url($jose_sig);

        return 'vapid t=' . $jwt . ',k=' . $keys['public_b64url'];
    }

    
    private static function ecdsa_der_to_jose($der) {
        $offset = 0;
        if (substr($der, $offset, 1) !== "\x30") {
            throw new \RuntimeException('ECDSA DER: expected SEQUENCE');
        }
        $offset++;
        
        $seq_len = ord($der[$offset]);
        $offset++;
        if ($seq_len & 0x80) {
            $n = $seq_len & 0x7f;
            $offset += $n; 
        }
        
        if (substr($der, $offset, 1) !== "\x02") {
            throw new \RuntimeException('ECDSA DER: expected INTEGER for R');
        }
        $offset++;
        $r_len = ord($der[$offset]);
        $offset++;
        $r = substr($der, $offset, $r_len);
        $offset += $r_len;
        
        if (substr($der, $offset, 1) !== "\x02") {
            throw new \RuntimeException('ECDSA DER: expected INTEGER for S');
        }
        $offset++;
        $s_len = ord($der[$offset]);
        $offset++;
        $s = substr($der, $offset, $s_len);

        
        
        $r = ltrim($r, "\x00");
        $s = ltrim($s, "\x00");
        return self::lpad($r, 32) . self::lpad($s, 32);
    }

    private function endpoint_origin($url) {
        $parts = wp_parse_url($url);
        if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
            return (string) $url;
        }
        $port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
        return $parts['scheme'] . '://' . $parts['host'] . $port;
    }

    
    
    

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

    public static function b64url_decode($str) {
        $pad = strlen($str) % 4;
        if ($pad) { $str .= str_repeat('=', 4 - $pad); }
        return (string) base64_decode(strtr($str, '-_', '+/'));
    }

    private static function lpad($bytes, $n) {
        $len = strlen($bytes);
        if ($len >= $n) { return $bytes; }
        return str_repeat("\x00", $n - $len) . $bytes;
    }
}
