<?php
/**
 * OAuth Controller.
 *
 * Handles OAuth2 authorization flow for Gmail / Outlook mailer integration
 * via the centralized NimblyMail OAuth proxy.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Rest;

use NimblyMail\SMTP\OAuth\BackendFactory;
use NimblyMail\SMTP\OAuth\Providers;
use NimblyMail\SMTP\Options;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Class OAuthController
 *
 * REST API controller for OAuth2 authorization, callback, and disconnect.
 *
 * @since 1.0.0
 */
class OAuthController extends AbstractController {

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

    /**
     * State transient TTL in seconds.
     *
     * Sized to tolerate first-time Google / Microsoft consent screens that
     * can take a few minutes on accounts with many existing app grants.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const STATE_TTL = 900;

    /**
     * Register REST API routes.
     *
     * @since 1.0.0
     */
    public function register_routes(): void {
        $provider_arg = array(
            'provider' => array(
                'type' => 'string',
                'enum' => array( 'google', 'microsoft' ),
            ),
        );

        register_rest_route(
            self::NAMESPACE,
            '/oauth/(?P<provider>google|microsoft)/authorize',
            array(
                'methods'             => 'GET',
                'callback'            => array( $this, 'authorize' ),
                'permission_callback' => array( $this, 'permissions_check' ),
                'args'                => $provider_arg,
            )
        );

        register_rest_route(
            self::NAMESPACE,
            '/oauth/(?P<provider>google|microsoft)/disconnect',
            array(
                'methods'             => 'POST',
                'callback'            => array( $this, 'disconnect' ),
                'permission_callback' => array( $this, 'permissions_check' ),
                'args'                => $provider_arg,
            )
        );
    }

    /**
     * Register admin-side hooks for OAuth callbacks.
     *
     * @since 1.0.0
     */
    public function init(): void {
        add_action( 'admin_init', array( $this, 'maybe_handle_callback' ) );
    }

    /**
     * Listen for OAuth callbacks on the NimblyMail settings page.
     *
     * Dispatches between two paths:
     *
     *   - Hosted proxy: the central oauth.nimblymail.com proxy redirects with
     *     `?nimblymail_oauth_callback=1&provider=...&nimbly_nonce=...&code=...&state=...&proxy_id=...`.
     *   - BYOK direct: Google / Microsoft redirect straight back to the
     *     registered redirect_uri (the same settings page URL) carrying only
     *     the standard OAuth response (`code`, `state`, optional `error`).
     *     We derive provider by looking up the state transient under each
     *     known provider key.
     *
     * Both paths land on the same admin page, so the registered redirect URI
     * for BYOK matches the proxy's callback_url verbatim — no extra query
     * var to bake into the user's Google Cloud / Azure registration.
     *
     * @since 1.0.0
     */
    public function maybe_handle_callback(): void {
        if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
            return;
        }

        // Both paths land on this admin page.
        $page = (string) filter_input( INPUT_GET, 'page', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
        if ( 'nimblymail-smtp-settings' !== $page ) {
            return;
        }

        // Hosted-proxy path is identified by the explicit flag the proxy appends.
        if ( filter_input( INPUT_GET, 'nimblymail_oauth_callback', FILTER_VALIDATE_BOOLEAN ) ) {
            $this->dispatch_proxy_callback();
            return;
        }

        // BYOK path: bare ?code=...&state=... from Google / Microsoft. Provider isn't in the URL because we can't make Google/MS append custom params — derive it by matching state across both providers' transients.
        $code  = (string) filter_input( INPUT_GET, 'code', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
        $state = (string) filter_input( INPUT_GET, 'state', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
        $error = (string) filter_input( INPUT_GET, 'error', FILTER_SANITIZE_FULL_SPECIAL_CHARS );

        if ( '' === $code && '' === $error ) {
            return;
        }

        if ( '' === $state ) {
            return;
        }

        $this->dispatch_byok_callback( $code, $state, $error );
    }

    /**
     * Validate and route a hosted-proxy callback to the existing token-exchange path.
     *
     * @since 1.0.0
     */
    private function dispatch_proxy_callback(): void {
        $provider = (string) filter_input( INPUT_GET, 'provider', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
        if ( ! in_array( $provider, array( 'google', 'microsoft' ), true ) ) {
            return;
        }

        // Proxy contract: the central oauth.nimblymail.com proxy renames the outbound `nonce` query arg to `nimbly_nonce` on its callback redirect, so the inbound name diverges from the outbound name in self::auth_url() on purpose. Do not unify without updating the proxy.
        $nonce = (string) filter_input( INPUT_GET, 'nimbly_nonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
        if ( ! wp_verify_nonce( $nonce, self::nonce_action( $provider ) ) ) {
            return;
        }

        $params = array(
            'provider' => $provider,
            'code'     => (string) filter_input( INPUT_GET, 'code', FILTER_SANITIZE_FULL_SPECIAL_CHARS ),
            'state'    => (string) filter_input( INPUT_GET, 'state', FILTER_SANITIZE_FULL_SPECIAL_CHARS ),
            'error'    => (string) filter_input( INPUT_GET, 'error', FILTER_SANITIZE_FULL_SPECIAL_CHARS ),
            'proxy_id' => (string) filter_input( INPUT_GET, 'proxy_id', FILTER_SANITIZE_FULL_SPECIAL_CHARS ),
        );

        $this->handle_callback( $params );
    }

    /**
     * Resolve provider by matching state, then route to the BYOK exchange.
     *
     * @since 1.0.0
     *
     * @param string $code  Authorization code returned by the provider.
     * @param string $state Opaque state value the plugin generated at authorize() time.
     * @param string $error Optional error code returned by the provider when consent was denied.
     */
    private function dispatch_byok_callback( string $code, string $state, string $error ): void {
        $provider = '';
        foreach ( array( 'google', 'microsoft' ) as $candidate ) {
            $stored = get_transient( self::state_transient_key( $candidate ) );
            if ( ! is_array( $stored ) ) {
                continue;
            }
            $stored_state = (string) ( $stored['state'] ?? '' );
            if ( '' !== $stored_state && hash_equals( $stored_state, $state ) ) {
                $provider = $candidate;
                break;
            }
        }

        if ( '' === $provider ) {
            // No matching transient — either expired, foreign request, or a hosted-proxy callback that lost its flag. Cannot proceed.
            return;
        }

        $this->handle_callback_byok( $provider, $code, $state, $error );
    }

    /**
     * Generate an authorization URL for the requested OAuth provider.
     *
     * Branches on the per-provider `auth_method` setting:
     *
     *   - 'hosted' (default) — hands off to the centralized NimblyMail OAuth
     *     proxy which holds the Google / Microsoft client credentials and
     *     manages PKCE on its side.
     *   - 'byok' — builds the provider's authorize URL directly, using the
     *     user-supplied client_id and a locally-generated PKCE verifier so
     *     no Google / Microsoft secret ever leaves the WordPress install.
     *
     * Both paths persist the same state transient shape — the callback
     * handler reads it back to verify CSRF state, route to the correct token
     * exchange, and (for BYOK) recover the PKCE verifier.
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request The REST request.
     * @return WP_REST_Response|WP_Error Authorization URL or error.
     */
    public function authorize( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $provider   = $request->get_param( 'provider' );
        $rate_error = $this->check_rate_limit( "oauth_authorize_{$provider}_rate", 10, 60 );
        if ( is_wp_error( $rate_error ) ) {
            return $rate_error;
        }

        $option_group = self::provider_to_option_group( $provider );
        $options      = Options::init();
        $auth_method  = (string) $options->get( $option_group, 'auth_method' );

        if ( 'byok' === $auth_method ) {
            return $this->authorize_byok( $provider, $option_group, $options );
        }

        return $this->authorize_hosted( $provider );
    }

    /**
     * Build a hosted-proxy authorization URL and stash the round-trip state.
     *
     * @since 1.0.0
     *
     * @param string $provider OAuth provider slug.
     *
     * @return WP_REST_Response
     */
    private function authorize_hosted( string $provider ): WP_REST_Response {
        $transient_key = self::state_transient_key( $provider );
        delete_transient( $transient_key );

        $state = wp_generate_password( 32, false );

        set_transient(
            $transient_key,
            array(
                'state'       => $state,
                'auth_method' => 'hosted',
                'verifier'    => '',
                'created_at'  => time(),
            ),
            self::STATE_TTL
        );

        $proxy_url    = self::PROXY_BASE_URL . '/authorize/' . rawurlencode( $provider );
        $callback_url = admin_url( 'admin.php?page=nimblymail-smtp-settings' );
        $nonce        = wp_create_nonce( self::nonce_action( $provider ) );

        $auth_url = add_query_arg(
            array(
                'site_url'     => get_home_url(),
                'state'        => $state,
                'callback_url' => $callback_url,
                'nonce'        => $nonce,
            ),
            $proxy_url
        );

        return new WP_REST_Response(
            array( 'url' => $auth_url ),
            200
        );
    }

    /**
     * Build a direct-to-provider authorize URL using user-supplied OAuth credentials.
     *
     * Generates a PKCE verifier + challenge locally and stores the verifier
     * in the same state transient the callback handler reads — verifier is
     * never shipped to the provider's authorize URL, only the SHA-256
     * challenge derived from it.
     *
     * @since 1.0.0
     *
     * @param string  $provider     OAuth provider slug.
     * @param string  $option_group Options group key ('gmail' | 'outlook').
     * @param Options $options      Options singleton.
     *
     * @return WP_REST_Response|WP_Error
     */
    private function authorize_byok( string $provider, string $option_group, Options $options ): WP_REST_Response|WP_Error {
        // Trim at the URL-building boundary too: WordPress's sanitize_text_field
        // trims values that flow through the REST settings save path, but values
        // injected via wp-config.php constants (NIMBLYMAIL_GMAIL_CLIENT_ID etc.)
        // bypass that sanitisation entirely. Untrimmed whitespace here would
        // render into the URL-encoded authorize URL (e.g. `abc%20`) while
        // DirectClient::__construct trims before /token — Google/Microsoft
        // reject the mismatch with opaque invalid_client.
        $client_id     = trim( (string) $options->get( $option_group, 'client_id' ) );
        $client_secret = trim( (string) $options->get( $option_group, 'client_secret' ) );

        if ( '' === $client_id || '' === $client_secret ) {
            return new WP_Error(
                'rest_missing_byok_credentials',
                __( 'Set your Client ID and Client Secret before connecting with your own OAuth app.', 'nimblymail-smtp' ),
                array( 'status' => 400 )
            );
        }

        $tenant   = trim( (string) $options->get( $option_group, 'tenant' ) );
        $auth_url = Providers::auth_url( $provider, $tenant );
        if ( '' === $auth_url ) {
            return new WP_Error(
                'rest_unknown_provider',
                __( 'Unknown OAuth provider.', 'nimblymail-smtp' ),
                array( 'status' => 400 )
            );
        }

        $transient_key = self::state_transient_key( $provider );
        delete_transient( $transient_key );

        $state     = wp_generate_password( 32, false );
        $verifier  = self::base64url_encode( random_bytes( 32 ) );
        $challenge = self::base64url_encode( hash( 'sha256', $verifier, true ) );

        // Pin the tenant value the authorize URL was built against into the
        // state transient. If the admin changes outlook.tenant between
        // authorize and callback, the callback's token-endpoint URL would
        // otherwise differ from the one the user actually consented at,
        // producing a confusing AADSTS50194 "issuer does not match"
        // failure. Pinning here lets handle_callback_byok use the same
        // tenant value at /token time as authorize_byok used at /authorize
        // time. Harmless for Google because Providers::token_url ignores tenant.
        set_transient(
            $transient_key,
            array(
                'state'       => $state,
                'auth_method' => 'byok',
                'verifier'    => $verifier,
                'tenant'      => $tenant,
                'created_at'  => time(),
            ),
            self::STATE_TTL
        );

        $redirect_uri = Providers::default_redirect_uri();

        // Google and Microsoft both reject non-HTTPS redirect URIs in
        // production (the sole exception is http://localhost / 127.0.0.1
        // for local dev) and will surface an opaque "redirect_uri_mismatch"
        // error long before the user even sees the consent screen. Surface
        // this early — and skip the noisy round-trip — when the WP install
        // is plainly serving admin over http://.
        //
        // wp_parse_url returns IPv6 hosts wrapped in brackets ("[::1]"), so
        // we strip them before comparing against the loopback allowlist.
        // The `.local` exemption is gated additionally on wp_get_environment_type
        // being 'local' (or WP_DEBUG when the env-type API isn't set), so a
        // production WP install configured on an intranet `.local` host
        // doesn't bypass the HTTPS guard and silently fail at the consent
        // screen.
        $redirect_host = (string) wp_parse_url( $redirect_uri, PHP_URL_HOST );
        $redirect_host = trim( $redirect_host, '[]' );
        $is_loopback   = in_array( $redirect_host, array( 'localhost', '127.0.0.1', '::1' ), true );
        $env_type      = function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : '';
        $is_local_env  = 'local' === $env_type || ( '' === $env_type && defined( 'WP_DEBUG' ) && WP_DEBUG );
        $is_local_host = $is_loopback || ( $is_local_env && str_ends_with( $redirect_host, '.local' ) );
        if ( ! str_starts_with( $redirect_uri, 'https://' ) && ! $is_local_host ) {
            return new WP_Error(
                'rest_byok_requires_https',
                __( 'Bring-your-own-keys OAuth requires this site to be served over HTTPS. Google and Microsoft reject non-HTTPS redirect URIs.', 'nimblymail-smtp' ),
                array( 'status' => 400 )
            );
        }

        $params = array(
            'response_type'         => 'code',
            'client_id'             => $client_id,
            'redirect_uri'          => $redirect_uri,
            'scope'                 => Providers::scopes( $provider ),
            'state'                 => $state,
            'code_challenge'        => $challenge,
            'code_challenge_method' => 'S256',
        );

        foreach ( Providers::extra_auth_params( $provider ) as $key => $value ) {
            $params[ $key ] = $value;
        }

        return new WP_REST_Response(
            array( 'url' => add_query_arg( $params, $auth_url ) ),
            200
        );
    }

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

    /**
     * Handle OAuth Proxy callback.
     *
     * Verifies the CSRF state, exchanges the authorization code via the proxy,
     * stores the tokens encrypted, and redirects back to the settings page.
     *
     * @since 1.0.0
     *
     * @param array<string, string> $params Callback parameters.
     */
    public function handle_callback( array $params ): void {
        $provider = $params['provider'];

        // Consume the state transient BEFORE the rate-limit check so a
        // captured (code, state) pair can never be replayed — even by an
        // attacker who deliberately races requests until the rate-limit
        // window slams shut. Doing this in the opposite order created a
        // replay window: rate-limit returns early, state stays alive,
        // the same code+state can be re-submitted on the next minute.
        // The state must die on first contact regardless of outcome.
        $transient_key = self::state_transient_key( $provider );
        $stored        = get_transient( $transient_key );
        delete_transient( $transient_key );

        $rate_error = $this->check_rate_limit( "oauth_callback_{$provider}_rate", 10, 60 );
        if ( is_wp_error( $rate_error ) ) {
            self::log_callback_failure(
                'error',
                $provider,
                array( 'reason' => 'rate_limited' )
            );
            $this->redirect_to_settings( 'error' );
            return;
        }

        if ( ! empty( $params['error'] ) ) {
            self::log_callback_failure(
                'error',
                $provider,
                array( 'proxy_error' => $params['error'] )
            );
            $this->redirect_to_settings( 'error' );
            return;
        }

        $state          = (string) ( $params['state'] ?? '' );
        $expected_state = is_array( $stored ) ? (string) ( $stored['state'] ?? '' ) : (string) $stored;

        if ( '' === $state || '' === $expected_state || ! hash_equals( $expected_state, $state ) ) {
            self::log_callback_failure(
                'state_mismatch',
                $provider,
                array(
                    'state_present'    => '' !== $state,
                    'expected_present' => '' !== $expected_state,
                )
            );
            $this->redirect_to_settings( 'state_mismatch' );
            return;
        }

        $code     = (string) ( $params['code'] ?? '' );
        $proxy_id = (string) ( $params['proxy_id'] ?? '' );

        if ( '' === $code || '' === $proxy_id ) {
            self::log_callback_failure(
                'missing_code',
                $provider,
                array(
                    'code_present'     => '' !== $code,
                    'proxy_id_present' => '' !== $proxy_id,
                )
            );
            $this->redirect_to_settings( 'missing_code' );
            return;
        }

        $options = Options::init();

        $response = wp_remote_post(
            self::PROXY_BASE_URL . '/token/' . rawurlencode( $provider ),
            array(
                'timeout' => 15,
                'headers' => array(
                    'Content-Type' => 'application/json',
                ),
                'body'    => wp_json_encode(
                    array(
                        'code'     => $code,
                        'proxy_id' => $proxy_id,
                    )
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            self::log_callback_failure(
                'token_error',
                $provider,
                array(
                    'stage'    => 'wp_remote_post',
                    'wp_error' => $response->get_error_message(),
                )
            );
            $this->redirect_to_settings( 'token_error' );
            return;
        }

        $status = (int) wp_remote_retrieve_response_code( $response );
        if ( $status < 200 || $status >= 300 ) {
            self::log_callback_failure(
                'token_error',
                $provider,
                array(
                    'stage'  => 'non_2xx',
                    'status' => $status,
                    'body'   => (string) wp_remote_retrieve_body( $response ),
                )
            );
            $this->redirect_to_settings( 'token_error' );
            return;
        }

        $body = json_decode( (string) wp_remote_retrieve_body( $response ), true );

        if ( ! is_array( $body ) || empty( $body['access_token'] ) || ! is_string( $body['access_token'] ) ) {
            self::log_callback_failure(
                'token_error',
                $provider,
                array(
                    'stage' => 'no_access_token',
                    'body'  => (string) wp_remote_retrieve_body( $response ),
                )
            );
            $this->redirect_to_settings( 'token_error' );
            return;
        }

        // The proxy provisions an install_id + install_secret on /token. Both
        // are required for subsequent signed /refresh and /revoke calls.
        // Without them the install cannot refresh — surface the error rather
        // than silently storing dead tokens.
        if ( empty( $body['install_id'] ) || empty( $body['install_secret'] ) || ! is_string( $body['install_id'] ) || ! is_string( $body['install_secret'] ) ) {
            self::log_callback_failure(
                'token_error',
                $provider,
                array(
                    'stage'          => 'missing_install_identity',
                    'install_id'     => ! empty( $body['install_id'] ),
                    'install_secret' => ! empty( $body['install_secret'] ),
                    'body_keys'      => array_keys( $body ),
                )
            );
            $this->redirect_to_settings( 'token_error' );
            return;
        }

        $expires_in = isset( $body['expires_in'] ) && is_numeric( $body['expires_in'] )
            ? min( max( (int) $body['expires_in'], 60 ), DAY_IN_SECONDS )
            : 3600;

        $option_group = self::provider_to_option_group( $provider );

        // Prefer the email surfaced by the proxy (extracted from the OIDC
        // id_token at /token time). For Google we can fall back to the
        // userinfo endpoint because our scope set includes openid+email.
        // For Microsoft we MUST NOT call Graph /me — the access token is
        // scoped to outlook.office.com/SMTP.Send only, User.Read belongs
        // to a different resource, and the call always 401s. A 401 would
        // leave $email empty and silently break send (OutlookMailer
        // throws "reconnect required"). Better to fail loudly here so the
        // admin sees a clear callback-stage error and re-runs OAuth,
        // rather than connecting and then discovering broken sends later.
        $proxy_email = isset( $body['email'] ) && is_string( $body['email'] )
            ? self::sanitize_email_response( $body['email'] )
            : '';
        if ( '' !== $proxy_email ) {
            $email = $proxy_email;
        } elseif ( 'microsoft' === $provider ) {
            self::log_callback_failure(
                'token_error',
                $provider,
                array(
                    'stage'  => 'no_email_from_proxy',
                    'reason' => 'microsoft_proxy_missing_email_claim',
                )
            );
            $this->redirect_to_settings( 'token_error' );
            return;
        } else {
            $email = self::sanitize_email_response( $this->fetch_authorized_email( $provider, $body['access_token'] ) );
        }

        $persist = array(
            'access_token'   => (string) $body['access_token'],
            'refresh_token'  => ! empty( $body['refresh_token'] ) && is_string( $body['refresh_token'] )
                ? (string) $body['refresh_token']
                : (string) $options->get( $option_group, 'refresh_token' ),
            'token_expiry'   => time() + $expires_in,
            'install_id'     => (string) $body['install_id'],
            'install_secret' => (string) $body['install_secret'],
        );

        if ( '' !== $email ) {
            $persist['email'] = $email;
        }

        $options->set(
            array(
                $option_group => $persist,
                'mail'        => array(
                    'mailer' => $option_group,
                ),
            )
        );

        $this->redirect_to_settings( 'success' );
    }

    /**
     * Exchange a BYOK authorization code for tokens, persist, and redirect.
     *
     * Mirrors handle_callback() but talks directly to Google / Microsoft
     * with the user-supplied client_id, client_secret, and locally-stored
     * PKCE verifier. Leaves install_id / install_secret blank — those are
     * proxy-only artifacts that BYOK flows neither need nor receive.
     *
     * @since 1.0.0
     *
     * @param string $provider OAuth provider slug.
     * @param string $code     Authorization code returned by the provider.
     * @param string $state    Opaque state value the plugin generated at authorize() time.
     * @param string $error    Optional error code returned by the provider when consent was denied.
     */
    private function handle_callback_byok( string $provider, string $code, string $state, string $error ): void {
        // Atomically consume the state transient FIRST so a captured
        // (code, state) pair can never be replayed — even when an attacker
        // is deliberately exhausting the rate-limit window to keep the
        // state alive across attempts. See handle_callback() for the same
        // ordering argument.
        $transient_key = self::state_transient_key( $provider );
        $stored        = get_transient( $transient_key );
        delete_transient( $transient_key );

        $rate_error = $this->check_rate_limit( "oauth_callback_{$provider}_rate", 10, 60 );
        if ( is_wp_error( $rate_error ) ) {
            self::log_callback_failure(
                'error',
                $provider,
                array(
                    'reason' => 'rate_limited',
                    'flow'   => 'byok',
                )
            );
            $this->redirect_to_settings( 'error' );
            return;
        }

        if ( '' !== $error ) {
            self::log_callback_failure(
                'error',
                $provider,
                array(
                    'provider_error' => $error,
                    'flow'           => 'byok',
                )
            );
            $this->redirect_to_settings( 'error' );
            return;
        }

        // Distinguish "transient expired/missing" from "wrong auth_method
        // stored" — they have different remediations (re-run authorize vs
        // check that auth_method wasn't toggled mid-flow) and conflating
        // them in logs sends support down the wrong rabbit hole.
        if ( ! is_array( $stored ) ) {
            self::log_callback_failure(
                'state_mismatch',
                $provider,
                array(
                    'reason' => 'state_transient_expired_or_missing',
                    'flow'   => 'byok',
                )
            );
            $this->redirect_to_settings( 'state_mismatch' );
            return;
        }

        $expected_state = (string) ( $stored['state'] ?? '' );
        $stored_method  = (string) ( $stored['auth_method'] ?? '' );
        $verifier       = (string) ( $stored['verifier'] ?? '' );
        $stored_tenant  = (string) ( $stored['tenant'] ?? '' );

        if ( 'byok' !== $stored_method ) {
            self::log_callback_failure(
                'state_mismatch',
                $provider,
                array(
                    'reason'        => 'auth_method_mismatch',
                    'stored_method' => $stored_method,
                    'flow'          => 'byok',
                )
            );
            $this->redirect_to_settings( 'state_mismatch' );
            return;
        }

        if ( '' === $state || '' === $expected_state || ! hash_equals( $expected_state, $state ) ) {
            self::log_callback_failure(
                'state_mismatch',
                $provider,
                array(
                    'state_present'    => '' !== $state,
                    'expected_present' => '' !== $expected_state,
                    'flow'             => 'byok',
                )
            );
            $this->redirect_to_settings( 'state_mismatch' );
            return;
        }

        if ( '' === $code ) {
            self::log_callback_failure(
                'missing_code',
                $provider,
                array(
                    'code_present' => false,
                    'flow'         => 'byok',
                )
            );
            $this->redirect_to_settings( 'missing_code' );
            return;
        }

        if ( '' === $verifier ) {
            // Should never happen — authorize_byok always writes a verifier — but fail closed.
            self::log_callback_failure(
                'token_error',
                $provider,
                array(
                    'stage' => 'missing_verifier',
                    'flow'  => 'byok',
                )
            );
            $this->redirect_to_settings( 'token_error' );
            return;
        }

        $option_group = self::provider_to_option_group( $provider );
        $options      = Options::init();

        $client_id     = (string) $options->get( $option_group, 'client_id' );
        $client_secret = (string) $options->get( $option_group, 'client_secret' );
        // Prefer the tenant value that was PINNED in the state transient at
        // authorize time. If the admin edited outlook.tenant between
        // authorize and callback the live Options value would point at a
        // different /token endpoint than the one the consent was bound to,
        // and Microsoft would respond with AADSTS50194 / AADSTS90002.
        // Fall back to Options only when the transient was written by an
        // older plugin build that did not yet pin tenant.
        $tenant = '' !== $stored_tenant ? $stored_tenant : (string) $options->get( $option_group, 'tenant' );

        if ( '' === $client_id || '' === $client_secret ) {
            self::log_callback_failure(
                'token_error',
                $provider,
                array(
                    'stage' => 'missing_credentials',
                    'flow'  => 'byok',
                )
            );
            $this->redirect_to_settings( 'token_error' );
            return;
        }

        $token_url = Providers::token_url( $provider, $tenant );
        if ( '' === $token_url ) {
            self::log_callback_failure(
                'token_error',
                $provider,
                array(
                    'stage' => 'unknown_provider',
                    'flow'  => 'byok',
                )
            );
            $this->redirect_to_settings( 'token_error' );
            return;
        }

        $response = wp_remote_post(
            $token_url,
            array(
                'timeout' => 15,
                'headers' => array(
                    'Content-Type' => 'application/x-www-form-urlencoded',
                    'Accept'       => 'application/json',
                ),
                'body'    => array(
                    'grant_type'    => 'authorization_code',
                    'code'          => $code,
                    'redirect_uri'  => Providers::default_redirect_uri(),
                    'client_id'     => $client_id,
                    'client_secret' => $client_secret,
                    'code_verifier' => $verifier,
                    'scope'         => Providers::scopes( $provider ),
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            self::log_callback_failure(
                'token_error',
                $provider,
                array(
                    'stage'    => 'wp_remote_post',
                    'wp_error' => $response->get_error_message(),
                    'flow'     => 'byok',
                )
            );
            $this->redirect_to_settings( 'token_error' );
            return;
        }

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

        if ( $status < 200 || $status >= 300 ) {
            self::log_callback_failure(
                'token_error',
                $provider,
                array(
                    'stage'  => 'non_2xx',
                    'status' => $status,
                    'body'   => $raw,
                    'flow'   => 'byok',
                )
            );
            $this->redirect_to_settings( 'token_error' );
            return;
        }

        $body = json_decode( $raw, true );
        if ( ! is_array( $body ) || empty( $body['access_token'] ) || ! is_string( $body['access_token'] ) ) {
            self::log_callback_failure(
                'token_error',
                $provider,
                array(
                    'stage' => 'no_access_token',
                    'body'  => $raw,
                    'flow'  => 'byok',
                )
            );
            $this->redirect_to_settings( 'token_error' );
            return;
        }

        $expires_in = isset( $body['expires_in'] ) && is_numeric( $body['expires_in'] )
            ? min( max( (int) $body['expires_in'], 60 ), DAY_IN_SECONDS )
            : 3600;

        // Prefer the id_token's email claim (Microsoft sets it under `openid + email`; Google does when openid is in scope) to avoid a follow-up userinfo round trip. Fall back to the provider's userinfo endpoint when the id_token is absent.
        $id_token_email = isset( $body['id_token'] ) && is_string( $body['id_token'] )
            ? self::email_from_id_token( $body['id_token'] )
            : '';
        $email          = '' !== $id_token_email
            ? $id_token_email
            : self::sanitize_email_response( $this->fetch_authorized_email( $provider, (string) $body['access_token'] ) );

        $persist = array(
            'access_token'   => (string) $body['access_token'],
            'refresh_token'  => ! empty( $body['refresh_token'] ) && is_string( $body['refresh_token'] )
                ? (string) $body['refresh_token']
                : (string) $options->get( $option_group, 'refresh_token' ),
            'token_expiry'   => time() + $expires_in,
            'install_id'     => '',
            'install_secret' => '',
        );

        if ( '' !== $email ) {
            $persist['email'] = $email;
        }

        $options->set(
            array(
                $option_group => $persist,
                'mail'        => array(
                    'mailer' => $option_group,
                ),
            )
        );

        $this->redirect_to_settings( 'success' );
    }

    /**
     * Extract the `email` (or `preferred_username` fallback) claim from an OIDC id_token.
     *
     * The id_token is a JWT. We only need the payload claims, never the
     * signature — trust comes from the TLS-direct response on the /token
     * endpoint. Returns '' on any parsing failure so the caller falls back
     * to the userinfo endpoint.
     *
     * @since 1.0.0
     *
     * @param string $id_token Raw JWT string returned by the provider.
     *
     * @return string Email address, or empty string when not present / parse failed.
     */
    private static function email_from_id_token( string $id_token ): string {
        $parts = explode( '.', $id_token );
        if ( count( $parts ) < 2 ) {
            return '';
        }

        $payload = strtr( $parts[1], '-_', '+/' );
        $pad     = strlen( $payload ) % 4;
        if ( 0 !== $pad ) {
            $payload .= str_repeat( '=', 4 - $pad );
        }

        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- JWT payload decode.
        $decoded = base64_decode( $payload, true );
        if ( false === $decoded ) {
            return '';
        }

        $claims = json_decode( $decoded, true );
        if ( ! is_array( $claims ) ) {
            return '';
        }

        if ( isset( $claims['email'] ) && is_string( $claims['email'] ) ) {
            $sanitized = self::sanitize_email_response( $claims['email'] );
            if ( '' !== $sanitized ) {
                return $sanitized;
            }
        }

        if ( isset( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ) {
            return self::sanitize_email_response( $claims['preferred_username'] );
        }

        return '';
    }

    /**
     * Map an OAuth provider name to its Options group key.
     *
     * The route regex constrains $provider to 'google'|'microsoft'.
     *
     * @since 1.0.0
     *
     * @param string $provider OAuth provider slug.
     *
     * @return string Options group key.
     */
    private static function provider_to_option_group( string $provider ): string {
        $map = array(
            'google'    => 'gmail',
            'microsoft' => 'outlook',
        );

        return $map[ $provider ];
    }

    /**
     * Build the user- and provider-scoped state transient key.
     *
     * @since 1.0.0
     *
     * @param string $provider OAuth provider slug.
     *
     * @return string Transient key.
     */
    private static function state_transient_key( string $provider ): string {
        return "nimblymail_oauth_{$provider}_state_" . get_current_user_id();
    }

    /**
     * Build the provider-scoped nonce action.
     *
     * Provider scoping prevents a captured Google-flow nonce from being
     * replayed against the Microsoft callback (or vice versa).
     *
     * @since 1.0.0
     *
     * @param string $provider OAuth provider slug.
     *
     * @return string Nonce action name.
     */
    private static function nonce_action( string $provider ): string {
        return "nimblymail_oauth_callback_{$provider}";
    }

    /**
     * Write a single diagnostic line describing an OAuth callback failure.
     *
     * OAuth callback failures are rare and almost always actionable (expired
     * state, blocked redirect, upstream provider scope/permission error). The
     * Connection Failed alert in the UI tells the admin to "check the error
     * log", so this method always logs (regardless of WP_DEBUG) to make that
     * pointer truthful. Sensitive values (code, state, tokens, install_secret)
     * are deliberately never logged — only structural facts (presence flags,
     * status code, proxy error string, upstream response body) so the line is
     * safe to share when filing a support request.
     *
     * @since 1.0.0
     *
     * @param string               $status   Redirect status that will be sent (error|state_mismatch|missing_code|token_error).
     * @param string               $provider OAuth provider slug.
     * @param array<string, mixed> $context  Structured context describing the failure branch.
     *
     * @return void
     */
    private static function log_callback_failure( string $status, string $provider, array $context = array() ): void {
        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
        error_log(
            sprintf(
                '[NimblyMail OAuth] callback failure: status=%s provider=%s context=%s',
                $status,
                $provider,
                wp_json_encode( $context )
            )
        );
    }

    /**
     * Sanitize an email value returned by the OAuth proxy or upstream provider.
     *
     * Returns an empty string when the value is not a valid email address.
     * Used both for the email captured at /token time and as a defense
     * against malformed userinfo responses.
     *
     * @since 1.0.0
     *
     * @param string $email Raw value from the upstream response.
     *
     * @return string Sanitized email address, or empty string when invalid.
     */
    private static function sanitize_email_response( string $email ): string {
        $sanitized = sanitize_email( trim( $email ) );

        return is_email( $sanitized ) ? $sanitized : '';
    }

    /**
     * Fetch the primary email address for the authorized account.
     *
     * @since 1.0.0
     *
     * @param string $provider     OAuth provider slug.
     * @param string $access_token Valid access token.
     *
     * @return string Email address, or empty string on failure.
     */
    private function fetch_authorized_email( string $provider, string $access_token ): string {
        $url = 'google' === $provider
            ? 'https://www.googleapis.com/oauth2/v2/userinfo'
            : 'https://graph.microsoft.com/v1.0/me';

        $response = wp_remote_get(
            $url,
            array(
                'timeout' => 10,
                'headers' => array(
                    'Authorization' => 'Bearer ' . $access_token,
                    'Accept'        => 'application/json',
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            return '';
        }

        if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
            return '';
        }

        $body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $body ) ) {
            return '';
        }

        if ( 'google' === $provider ) {
            return (string) ( $body['email'] ?? '' );
        }

        return (string) ( $body['mail'] ?? $body['userPrincipalName'] ?? '' );
    }

    /**
     * Redirect to the NimblyMail settings page with an OAuth status parameter.
     *
     * @since 1.0.0
     *
     * @param string $status The OAuth result status code.
     */
    private function redirect_to_settings( string $status ): void {
        $allowed = array( 'success', 'error', 'state_mismatch', 'missing_code', 'token_error' );
        $status  = in_array( $status, $allowed, true ) ? $status : 'error';

        wp_safe_redirect(
            admin_url( 'admin.php?page=nimblymail-smtp-settings&oauth=' . $status . '#/settings/connection' )
        );
        exit;
    }

    /**
     * Disconnect OAuth by signing a /revoke call to the proxy and clearing tokens locally.
     *
     * The /revoke call is best-effort — if the proxy is unreachable, local
     * tokens are still cleared so the admin's intent ("disconnect this
     * account") is honored. Disconnecting an install also clears its
     * install_secret so subsequent /refresh attempts will fail closed.
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request The REST request.
     * @return WP_REST_Response|WP_Error Success response or rate-limit error.
     */
    public function disconnect( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $provider   = $request->get_param( 'provider' );
        $rate_error = $this->check_rate_limit( "oauth_disconnect_{$provider}_rate", 5, 60 );
        if ( is_wp_error( $rate_error ) ) {
            return $rate_error;
        }

        $option_group = self::provider_to_option_group( $provider );
        $options      = Options::init();

        // Best-effort revoke at whichever backend is currently configured. Errors do not block the local clear.
        $backend = BackendFactory::for_provider( $provider );
        if ( $backend->is_provisioned() ) {
            $backend->revoke();
        }

        $options->set(
            array(
                $option_group => array(
                    'access_token'   => '',
                    'refresh_token'  => '',
                    'token_expiry'   => 0,
                    'email'          => '',
                    'install_id'     => '',
                    'install_secret' => '',
                ),
            )
        );

        /* translators: %s: OAuth provider name (e.g. "Google" or "Microsoft"). */
        $message = sprintf( __( '%s account disconnected.', 'nimblymail-smtp' ), ucfirst( $provider ) );

        return new WP_REST_Response(
            array( 'message' => $message ),
            200
        );
    }
}
