<?php
/**
 * Direct-to-provider OAuth client (BYOK).
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\OAuth;

use NimblyMail\SMTP\OAuth\Contracts\OAuthBackend;

defined( 'ABSPATH' ) || exit;

/**
 * Class DirectClient
 *
 * Implements {@see OAuthBackend} by talking straight to the upstream
 * provider's token + revoke endpoints with a user-supplied client_id and
 * client_secret — the bring-your-own-keys path. No proxy involvement, no
 * install_id / install_secret bookkeeping.
 *
 * Google has a real /revoke endpoint that invalidates the refresh token
 * immediately. Microsoft v2.0 has no token-revoke endpoint at all; the
 * disconnect intent is local-only and we degrade {@see self::revoke()} to a
 * synthetic 204 so the caller's "clear local state" path runs uniformly.
 *
 * @since 1.0.0
 */
class DirectClient implements OAuthBackend {

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

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

    /**
     * User-supplied OAuth client id.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private string $client_id;

    /**
     * User-supplied OAuth client secret.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private string $client_secret;

    /**
     * Microsoft tenant identifier ('common' default). Ignored for Google.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private string $tenant;

    /**
     * Last-known access token (fallback for revoke when no refresh_token is available).
     *
     * @since 1.0.0
     *
     * @var string
     */
    private string $access_token;

    /**
     * Last-known refresh token (preferred for Google /revoke — kills the entire grant).
     *
     * @since 1.0.0
     *
     * @var string
     */
    private string $refresh_token;

    /**
     * Constructor.
     *
     * All credential strings are trimmed because the BYOK UI accepts pasted
     * input and Google / Microsoft both reject tokens with stray surrounding
     * whitespace with opaque 400 errors that are painful to diagnose.
     *
     * @since 1.0.0
     *
     * @param string $provider      OAuth provider slug.
     * @param string $client_id     User-supplied client id.
     * @param string $client_secret User-supplied client secret.
     * @param string $tenant        Microsoft tenant ('common' | 'organizations' | 'consumers' | GUID). Defaults to 'common'.
     * @param string $access_token  Optional last-known access token (fallback for revoke).
     * @param string $refresh_token Optional last-known refresh token (preferred for revoke on Google).
     */
    public function __construct( string $provider, string $client_id, string $client_secret, string $tenant = 'common', string $access_token = '', string $refresh_token = '' ) {
        $this->provider      = $provider;
        $this->client_id     = trim( $client_id );
        $this->client_secret = trim( $client_secret );
        $tenant              = trim( $tenant );
        $this->tenant        = '' === $tenant ? 'common' : $tenant;
        $this->access_token  = trim( $access_token );
        $this->refresh_token = trim( $refresh_token );
    }

    /**
     * Whether this client has the credentials it needs to talk to the provider.
     *
     * @since 1.0.0
     *
     * @return bool
     */
    public function is_provisioned(): bool {
        return Providers::exists( $this->provider ) && '' !== $this->client_id && '' !== $this->client_secret;
    }

    /**
     * Exchange a refresh_token for a fresh access_token via the provider's /token endpoint.
     *
     * Microsoft v2.0 requires the original scope on refresh; Google ignores
     * it harmlessly, so we send {@see Providers::scopes()} for both.
     *
     * @since 1.0.0
     *
     * @param string $refresh_token Refresh token to exchange.
     *
     * @return array{ok: bool, status: int, data: array<string, mixed>, error?: string}
     */
    public function refresh( string $refresh_token ): array {
        if ( ! $this->is_provisioned() ) {
            return array(
                'ok'     => false,
                'status' => 0,
                'data'   => array(),
                'error'  => 'not_provisioned',
            );
        }

        $url = Providers::token_url( $this->provider, $this->tenant );
        if ( '' === $url ) {
            return array(
                'ok'     => false,
                'status' => 0,
                'data'   => array(),
                'error'  => 'unknown_provider',
            );
        }

        $body = array(
            'grant_type'    => 'refresh_token',
            'client_id'     => $this->client_id,
            'client_secret' => $this->client_secret,
            'refresh_token' => $refresh_token,
            'scope'         => Providers::scopes( $this->provider ),
        );

        $response = wp_remote_post(
            $url,
            array(
                'timeout' => self::TIMEOUT,
                'headers' => array(
                    'Content-Type' => 'application/x-www-form-urlencoded',
                    'Accept'       => 'application/json',
                ),
                'body'    => $body,
            )
        );

        return self::parse_response( $response );
    }

    /**
     * Revoke the current authorization at the provider, or local-only if unsupported.
     *
     * Google: POSTs a token to the revoke endpoint. Per Google's docs
     * (https://developers.google.com/identity/protocols/oauth2/web-server#tokenrevoke)
     * revoking the REFRESH token invalidates the entire grant — every
     * associated access token plus future refreshes — whereas revoking just
     * the access token leaves the refresh path live and the user "still
     * connected" from Google's perspective even after we cleared local
     * state. We therefore prefer the refresh_token when we have one and
     * only fall back to the access_token (last resort: still a no-op for
     * the grant, but better than nothing) when no refresh was issued.
     * Either way the revoke endpoint returns 200 even for an
     * already-revoked or unknown token, so the call is idempotent.
     *
     * Microsoft: there is no revoke endpoint in v2.0 — the caller must
     * clear local tokens and (optionally) ask the user to revoke at
     * https://account.microsoft.com/consent or in the Azure tenant admin
     * UI. Returns ok=true with status=204 so the local-clear path
     * proceeds uniformly.
     *
     * @since 1.0.0
     *
     * @return array{ok: bool, status: int, data: array<string, mixed>, error?: string}
     */
    public function revoke(): array {
        $revoke_url = Providers::revoke_url( $this->provider );

        if ( null === $revoke_url ) {
            return array(
                'ok'     => true,
                'status' => 204,
                'data'   => array(),
            );
        }

        $token = '' !== $this->refresh_token ? $this->refresh_token : $this->access_token;

        if ( '' === $token ) {
            return array(
                'ok'     => false,
                'status' => 0,
                'data'   => array(),
                'error'  => 'no_token',
            );
        }

        $response = wp_remote_post(
            $revoke_url,
            array(
                'timeout' => self::TIMEOUT,
                'headers' => array(
                    'Content-Type' => 'application/x-www-form-urlencoded',
                    'Accept'       => 'application/json',
                ),
                'body'    => array( 'token' => $token ),
            )
        );

        return self::parse_response( $response );
    }

    /**
     * Parse a wp_remote_post response into the structured shape callers expect.
     *
     * @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 );

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

        $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,
        );
    }
}
