<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Connect;

/**
 * EdDSA pairing-JWT verifier for the install_token / callback flow.
 *
 * Mirrors {@see \Numinix\Seekmodo\Pairing::verify_jwt()} from the
 * Zen Cart connector (zc_plugins/Seekmodo/v1.0.18). Both connectors
 * pair against the same seekmodo.com /api/connect/complete endpoint,
 * which mints an Ed25519-signed JWT (alg=EdDSA, JWKS at
 * https://seekmodo.com/.well-known/jwks.json) and POSTs
 * `{token, install_token}` with a `X-Seekmodo-Install-Token` header
 * to the plugin callback URL.
 *
 * The verifier here intentionally does NOT depend on a userland JWT
 * library — `firebase/php-jwt` would mean adding a Composer dep that
 * we don't otherwise need on a WP-managed install. The shape of the
 * problem is small enough (one alg, one curve, three b64url decodes)
 * that handwriting it is the lower-risk choice.
 *
 * Required PHP extension: `sodium` (ext-sodium). It's part of core
 * PHP since 7.2 and shipped on the default cPanel/EasyApache PHP
 * builds, but not always loaded. The verifier surfaces a
 * cPanel-specific install hint when sodium is missing so the
 * merchant has actionable advice instead of a generic error in the
 * seekmodo.com pairing dialog.
 *
 * Cache: the JWKS fetch is cached as a wp_transient for 5 minutes,
 * keyed by `(jwksUrl, kid)`. Pairing is a one-shot user action so
 * a brief cache miss is fine; the cache is here mostly to absorb
 * the rare case where seekmodo.com retries the callback POST and
 * the second hit shouldn't pay for another JWKS round-trip.
 */
final class Pairing
{
    private const JWKS_TRANSIENT_PREFIX = 'seekmodo_wp_jwk_';
    private const JWKS_CACHE_SECONDS = 300;

    /**
     * Verify the EdDSA-signed pairing JWT and return its decoded
     * claims. Throws on any signature or shape failure.
     *
     * @return array<string,mixed>
     */
    public static function verifyJwt(string $jwt, string $jwksUrl): array
    {
        $parts = explode('.', $jwt);
        if (count($parts) !== 3) {
            throw new \RuntimeException('malformed jwt');
        }
        [$h, $c, $s] = $parts;

        $header = json_decode(self::b64uDecode($h), true);
        if (!is_array($header) || ($header['alg'] ?? '') !== 'EdDSA') {
            throw new \RuntimeException('unsupported alg');
        }
        $kid = (string) ($header['kid'] ?? '');
        if ($kid === '' || !preg_match('~^[A-Za-z0-9._\-]+$~', $kid)) {
            throw new \RuntimeException('missing or invalid kid');
        }

        $claims = json_decode(self::b64uDecode($c), true);
        if (!is_array($claims)) {
            throw new \RuntimeException('malformed claims');
        }

        if (!function_exists('sodium_crypto_sign_verify_detached')) {
            $phpVersion = explode('.', PHP_VERSION);
            $phpMajorMinor = ($phpVersion[0] ?? '8') . ($phpVersion[1] ?? '1');
            throw new \RuntimeException(
                'PHP sodium extension is required but not loaded on this WordPress host (PHP '
                . esc_html(PHP_VERSION) . '). On cPanel/EasyApache hosts (most common): '
                . '`yum install -y ea-php' . esc_html($phpMajorMinor) . '-php-sodium` as root, '
                . 'then PHP-FPM will be restarted automatically. On Debian/Ubuntu: '
                . '`apt-get install -y php' . esc_html((string) ($phpVersion[0] ?? '8')) . '.' . esc_html((string) ($phpVersion[1] ?? '1'))
                . '-sodium && systemctl restart php' . esc_html((string) ($phpVersion[0] ?? '8')) . '.' . esc_html((string) ($phpVersion[1] ?? '1'))
                . '-fpm`. Then click Connect again from Settings -> Seekmodo Search.'
            );
        }

        $publicKey = self::resolvePublicKey($jwksUrl, $kid);
        $signed = $h . '.' . $c;
        $sig = self::b64uDecode($s);
        if (!sodium_crypto_sign_verify_detached($sig, $signed, $publicKey)) {
            throw new \RuntimeException('jwt signature invalid');
        }

        return $claims;
    }

    /**
     * Fetch the JWKS, find the matching kid, and return the raw
     * 32-byte Ed25519 public key. Cached as a wp_transient for 5
     * minutes — keyed by `(jwksUrl, kid)` so a key rotation that
     * keeps the same JWKS URL but flips kid invalidates cleanly.
     */
    private static function resolvePublicKey(string $jwksUrl, string $kid): string
    {
        $cacheKey = self::JWKS_TRANSIENT_PREFIX . substr(hash('sha256', $jwksUrl . '|' . $kid), 0, 32);
        if (function_exists('get_transient')) {
            $cached = get_transient($cacheKey);
            if (is_string($cached) && $cached !== '') {
                return $cached;
            }
        }

        $body = '';
        if (function_exists('wp_remote_get')) {
            $resp = wp_remote_get($jwksUrl, [
                'timeout' => 5,
                'redirection' => 2,
                'headers' => ['User-Agent' => 'Seekmodo-WordPress-Connector/1.0'],
            ]);
            if (is_wp_error($resp)) {
                throw new \RuntimeException('jwks fetch failed: ' . esc_html($resp->get_error_message()));
            }
            $code = (int) wp_remote_retrieve_response_code($resp);
            if ($code < 200 || $code >= 300) {
                throw new \RuntimeException('jwks fetch returned HTTP ' . esc_html((string) $code));
            }
            $body = (string) wp_remote_retrieve_body($resp);
        } else {
            $ctx = stream_context_create([
                'http' => ['timeout' => 5, 'header' => "User-Agent: Seekmodo-WordPress-Connector/1.0\r\n"],
                'https' => ['timeout' => 5, 'header' => "User-Agent: Seekmodo-WordPress-Connector/1.0\r\n"],
            ]);
            $body = (string) @file_get_contents($jwksUrl, false, $ctx);
        }
        if ($body === '') {
            throw new \RuntimeException('jwks fetch returned empty body');
        }
        $jwks = json_decode($body, true);
        if (!is_array($jwks) || empty($jwks['keys']) || !is_array($jwks['keys'])) {
            throw new \RuntimeException('jwks malformed');
        }

        foreach ($jwks['keys'] as $k) {
            if (!is_array($k)) continue;
            if (($k['kid'] ?? '') !== $kid) continue;
            if (($k['kty'] ?? '') !== 'OKP' || ($k['crv'] ?? '') !== 'Ed25519') continue;
            $x = (string) ($k['x'] ?? '');
            if ($x === '') continue;
            $raw = self::b64uDecode($x);
            if (function_exists('set_transient')) {
                set_transient($cacheKey, $raw, self::JWKS_CACHE_SECONDS);
            }
            return $raw;
        }

        throw new \RuntimeException('jwks did not contain kid ' . esc_html($kid));
    }

    private static function b64uDecode(string $s): string
    {
        $s = strtr($s, '-_', '+/');
        $pad = strlen($s) % 4;
        if ($pad > 0) {
            $s .= str_repeat('=', 4 - $pad);
        }
        $decoded = base64_decode($s, true);
        if ($decoded === false) {
            throw new \RuntimeException('invalid base64url payload');
        }
        return $decoded;
    }
}
