<?php
/**
 * NimblyMail OAuth Proxy client.
 *
 * Wraps the signed `/refresh` and `/revoke` calls to oauth.nimblymail.com.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\OAuth;

use NimblyMail\SMTP\OAuth\Contracts\OAuthBackend;

defined( 'ABSPATH' ) || exit;

/**
 * Class ProxyClient
 *
 * Per-install signed-request client for the NimblyMail OAuth proxy.
 *
 * The proxy issues an `install_id` and `install_secret` on first /token
 * exchange. The plugin stores both encrypted at rest and signs subsequent
 * /refresh and /revoke requests with HMAC-SHA256 over a canonical string,
 * binding each request to the install identity, the current timestamp, a
 * single-use nonce, and (for refresh) the SHA-256 hash of the refresh token
 * being submitted. The proxy verifies the signature, the timestamp window
 * (±300s), the nonce uniqueness, and that the submitted token belongs to
 * this install before forwarding to the upstream provider.
 *
 * @since 1.0.0
 */
class ProxyClient implements OAuthBackend {

    /**
     * Base URL of the NimblyMail OAuth proxy.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const BASE_URL = 'https://oauth.nimblymail.com';

    /**
     * HTTP request timeout in seconds.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const TIMEOUT = 15;

    /**
     * Canonical-string version tag.
     *
     * Bumped only when the canonical layout or HMAC algorithm changes; the
     * proxy verifies this prefix.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const VERSION = 'v1';

    /**
     * OAuth provider slug ("google" | "microsoft").
     *
     * @since 1.0.0
     *
     * @var string
     */
    private string $provider;

    /**
     * Install ID issued by the proxy at /token time.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private string $install_id;

    /**
     * Install secret issued by the proxy at /token time, base64url-encoded.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private string $install_secret;

    /**
     * Constructor.
     *
     * @since 1.0.0
     *
     * @param string $provider       OAuth provider slug ("google" or "microsoft").
     * @param string $install_id     UUID issued by the proxy.
     * @param string $install_secret Base64url-encoded HMAC secret issued by the proxy.
     */
    public function __construct( string $provider, string $install_id, string $install_secret ) {
        $this->provider       = $provider;
        $this->install_id     = $install_id;
        $this->install_secret = $install_secret;
    }

    /**
     * Whether this client has been provisioned with install credentials.
     *
     * @since 1.0.0
     *
     * @return bool
     */
    public function is_provisioned(): bool {
        return '' !== $this->install_id && '' !== $this->install_secret;
    }

    /**
     * Refresh the access token via the signed /refresh endpoint.
     *
     * Signs the request with HMAC-SHA256 over a canonical string that binds
     * the install identity, timestamp, nonce, and refresh-token hash. The
     * proxy verifies all four before forwarding to the upstream provider.
     *
     * @since 1.0.0
     *
     * @param string $refresh_token The refresh token to exchange.
     *
     * @return array{ok: bool, status: int, data: array<string, mixed>, error?: string} Structured response.
     */
    public function refresh( string $refresh_token ): array {
        if ( ! $this->is_provisioned() ) {
            return array(
                'ok'     => false,
                'status' => 0,
                'data'   => array(),
                'error'  => 'not_provisioned',
            );
        }

        $timestamp = time();
        $nonce     = bin2hex( random_bytes( 16 ) );
        $token_h   = hash( 'sha256', $refresh_token );

        $canonical = implode(
            "\n",
            array(
                self::VERSION,
                'refresh',
                $this->provider,
                $this->install_id,
                (string) $timestamp,
                $nonce,
                $token_h,
            )
        );

        $signature = self::sign( $canonical, $this->install_secret );

        $url = self::BASE_URL . '/refresh/' . rawurlencode( $this->provider );

        $response = wp_remote_post(
            $url,
            array(
                'timeout' => self::TIMEOUT,
                'headers' => array(
                    'Content-Type'   => 'application/json',
                    'X-NM-Install'   => $this->install_id,
                    'X-NM-Timestamp' => (string) $timestamp,
                    'X-NM-Nonce'     => $nonce,
                    'X-NM-Signature' => $signature,
                ),
                'body'    => wp_json_encode( array( 'refresh_token' => $refresh_token ) ),
            )
        );

        return self::parse_response( $response );
    }

    /**
     * Revoke the install via the signed /revoke endpoint.
     *
     * Best-effort: returns ok=false on transport error so the caller can still
     * clear local state. The proxy is idempotent — revoking an already-removed
     * install returns 204.
     *
     * @since 1.0.0
     *
     * @return array{ok: bool, status: int, data: array<string, mixed>, error?: string} Structured response.
     */
    public function revoke(): array {
        if ( ! $this->is_provisioned() ) {
            return array(
                'ok'     => false,
                'status' => 0,
                'data'   => array(),
                'error'  => 'not_provisioned',
            );
        }

        $timestamp = time();
        $nonce     = bin2hex( random_bytes( 16 ) );

        $canonical = implode(
            "\n",
            array(
                self::VERSION,
                'revoke',
                $this->provider,
                $this->install_id,
                (string) $timestamp,
                $nonce,
            )
        );

        $signature = self::sign( $canonical, $this->install_secret );

        $url = self::BASE_URL . '/revoke/' . rawurlencode( $this->provider );

        $response = wp_remote_post(
            $url,
            array(
                'timeout' => self::TIMEOUT,
                'headers' => array(
                    'X-NM-Install'   => $this->install_id,
                    'X-NM-Timestamp' => (string) $timestamp,
                    'X-NM-Nonce'     => $nonce,
                    'X-NM-Signature' => $signature,
                ),
                'body'    => '',
            )
        );

        return self::parse_response( $response );
    }

    /**
     * Compute base64url(HMAC-SHA256(decoded_secret, canonical)).
     *
     * The install_secret is delivered base64url-encoded but the HMAC must use
     * the raw bytes — that's the value the proxy signs and verifies against.
     *
     * @since 1.0.0
     *
     * @param string $canonical Canonical string to sign.
     * @param string $secret    Base64url-encoded HMAC secret.
     *
     * @return string Base64url-encoded HMAC-SHA256 signature.
     */
    private static function sign( string $canonical, string $secret ): string {
        $key_bytes = self::base64url_decode( $secret );
        $mac       = hash_hmac( 'sha256', $canonical, $key_bytes, true );

        return self::base64url_encode( $mac );
    }

    /**
     * Decode base64url to raw bytes (no padding required).
     *
     * @since 1.0.0
     *
     * @param string $input Base64url-encoded input.
     *
     * @return string Raw bytes (empty string on decode failure).
     */
    private static function base64url_decode( string $input ): string {
        $padded = strtr( $input, '-_', '+/' );
        $pad    = strlen( $padded ) % 4;
        if ( 0 !== $pad ) {
            $padded .= str_repeat( '=', 4 - $pad );
        }

        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Required for base64url.
        $decoded = base64_decode( $padded, true );

        return false === $decoded ? '' : $decoded;
    }

    /**
     * Encode raw bytes as base64url (no padding).
     *
     * @since 1.0.0
     *
     * @param string $input Raw bytes.
     *
     * @return string Base64url string.
     */
    private static function base64url_encode( string $input ): string {
        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for base64url.
        return rtrim( strtr( base64_encode( $input ), '+/', '-_' ), '=' );
    }

    /**
     * Parse a wp_remote_post response into the structured shape used by callers.
     *
     * @since 1.0.0
     *
     * @param array|\WP_Error $response Result from wp_remote_post().
     *
     * @return array{ok: bool, status: int, data: array<string, mixed>, error?: string}
     */
    private static function parse_response( $response ): array {
        if ( is_wp_error( $response ) ) {
            return array(
                'ok'     => false,
                'status' => 0,
                'data'   => array(),
                'error'  => 'transport_error',
            );
        }

        $status = (int) wp_remote_retrieve_response_code( $response );
        $body   = (string) wp_remote_retrieve_body( $response );

        // 204 No Content (revoke success) — no body to parse.
        if ( '' === $body ) {
            return array(
                'ok'     => $status >= 200 && $status < 300,
                'status' => $status,
                'data'   => array(),
            );
        }

        $content_type = (string) wp_remote_retrieve_header( $response, 'content-type' );
        if ( '' !== $content_type && ! str_starts_with( strtolower( $content_type ), 'application/json' ) ) {
            return array(
                'ok'     => false,
                'status' => $status,
                'data'   => array(),
                'error'  => 'invalid_content_type',
            );
        }

        $decoded = json_decode( $body, true );
        if ( ! is_array( $decoded ) ) {
            return array(
                'ok'     => false,
                'status' => $status,
                'data'   => array(),
                'error'  => 'invalid_json',
            );
        }

        return array(
            'ok'     => $status >= 200 && $status < 300,
            'status' => $status,
            'data'   => $decoded,
        );
    }
}
