<?php
/**
 * Gmail Mailer.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Mailers;

use NimblyMail\SMTP\OAuth\BackendFactory;
use NimblyMail\SMTP\OAuth\Providers;
use NimblyMail\SMTP\Options;
use PHPMailer\PHPMailer\PHPMailer;

defined( 'ABSPATH' ) || exit;

/**
 * Class GmailMailer
 *
 * Sends email via Google's OAuth2 SMTP transport. Uses the XOAUTH2 authentication
 * mechanism with tokens obtained through Google's OAuth2 flow. Automatically
 * refreshes expired access tokens before each send.
 *
 * @since 1.0.0
 * @since 1.0.3 client_id marked encrypted for REST masking; refresh-lock stale-preemption made race-safe.
 */
class GmailMailer extends AbstractMailer {

    /**
     * {@inheritdoc}
     */
    public function get_slug(): string {
        return 'gmail';
    }

    /**
     * {@inheritdoc}
     */
    public function get_title(): string {
        return __( 'Gmail / Google Workspace', 'nimblymail-smtp' );
    }

    /**
     * Get the mailer description shown in the mailer-cards selector.
     *
     * @since 1.0.0
     *
     * @return string
     */
    public function get_description(): string {
        return __( 'Authenticate via Google OAuth.', 'nimblymail-smtp' );
    }

    /**
     * Get the settings schema for this mailer as a section-shaped array.
     *
     * Includes the dynamic OAuth authorize/disconnect button whose label and
     * state reflect whether a valid access token is currently stored.
     *
     * @since 1.0.0 TEMPORARY (OAuth proxy pending approval): forced BYOK-only —
     *              hosted "Connect"/"I have my own keys" toggle removed, own-key
     *              fields shown by default with a setup-docs link.
     * @since 1.0.3 Marked client_id `encrypted` so it is masked in REST
     *              responses like its paired client_secret field — it was
     *              already stored encrypted, only the response masking was missing.
     *
     * @return array Section descriptor with id, title, description, help_url, and fields.
     */
    public function get_settings_fields(): array {
        $is_authorized = $this->is_authorized();

        // TEMPORARY (OAuth proxy pending approval): the hosted NimblyMail OAuth
        // proxy is not yet approved for Google, so the hosted authorize_btn (with
        // its "I have my own keys" toggle) and the hidden auth_method field are
        // removed. The BYOK credential fields are shown unconditionally and the
        // backend is forced to DirectClient via the byok default in Options.
        // Revert by restoring the hidden auth_method field + hosted authorize_btn,
        // re-adding the visible_when guards, and dropping the docs link below.
        return array(
            'id'          => 'gmail_settings',
            'title'       => __( 'Gmail / Google Workspace', 'nimblymail-smtp' ),
            'description' => __( "Connect your Google account to send emails via Gmail's OAuth-protected SMTP.", 'nimblymail-smtp' ),
            'fields'      => array(
                array(
                    'id'       => 'byok_docs',
                    'group'    => 'gmail',
                    'title'    => __( 'Setup Guide', 'nimblymail-smtp' ),
                    'type'     => 'link',
                    'label'    => __( 'How to create your Google OAuth credentials', 'nimblymail-smtp' ),
                    'url'      => 'https://nimblymail.com/documentation/gmail/',
                    'external' => true,
                ),
                array(
                    'id'          => 'client_id',
                    'group'       => 'gmail',
                    'title'       => __( 'Client ID', 'nimblymail-smtp' ),
                    'type'        => 'text',
                    'encrypted'   => true,
                    'placeholder' => '123456789-abc.apps.googleusercontent.com',
                    'description' => __( 'OAuth 2.0 Client ID from your Google Cloud project (APIs & Services → Credentials).', 'nimblymail-smtp' ),
                ),
                array(
                    'id'          => 'client_secret',
                    'group'       => 'gmail',
                    'title'       => __( 'Client Secret', 'nimblymail-smtp' ),
                    'type'        => 'password',
                    'encrypted'   => true,
                    'description' => __( 'OAuth 2.0 Client Secret. Stored encrypted at rest.', 'nimblymail-smtp' ),
                ),
                array(
                    'id'          => 'redirect_uri',
                    'group'       => 'gmail',
                    'title'       => __( 'Authorized Redirect URI', 'nimblymail-smtp' ),
                    'type'        => 'copyable-uri',
                    'value'       => Providers::default_redirect_uri(),
                    'description' => __( 'Copy this URL exactly and add it to your Google Cloud OAuth 2.0 Client → Authorized redirect URIs. A mismatch returns "redirect_uri_mismatch" on the next Connect attempt.', 'nimblymail-smtp' ),
                ),
                array(
                    // Primary Connect button rendered after the BYOK credential
                    // fields. No byok_toggle, so it renders as a plain primary
                    // "Connect to Google" CTA for the user's configured app.
                    'id'                => 'authorize_btn_byok',
                    'group'             => 'gmail',
                    'title'             => '',
                    'type'              => 'button',
                    'action'            => 'oauth/google/authorize',
                    'disconnect_action' => 'oauth/google/disconnect',
                    'status_field'      => true,
                    'is_authorized'     => $is_authorized,
                    'icon'              => NIMBLYMAIL_URL . 'assets/images/providers/google.png',
                    'label'             => $is_authorized
                        ? __( 'Connected to Google', 'nimblymail-smtp' )
                        : __( 'Connect to Google', 'nimblymail-smtp' ),
                    'connected_label'   => __( 'Connected', 'nimblymail-smtp' ),
                    'disconnect_label'  => __( 'Disconnect', 'nimblymail-smtp' ),
                    // Mirror auth_method_group so the Vue handler can scope its
                    // pre-OAuth save to this provider's option group instead of
                    // committing the entire dirty map.
                    'auth_method_group' => 'gmail',
                ),
            ),
        );
    }

    /**
     * Check if OAuth is authorized.
     *
     * @since 1.0.0
     *
     * @return bool True if an access token is stored.
     */
    public function is_authorized(): bool {
        return ! empty( $this->get_option( 'access_token' ) );
    }

    /**
     * Configure the PHPMailer instance for Gmail OAuth SMTP transport.
     *
     * Sets up PHPMailer to use smtp.gmail.com with XOAUTH2 authentication.
     * Automatically refreshes the access token if expired before configuring.
     *
     * @since 1.0.0
     *
     * @param PHPMailer $phpmailer The PHPMailer instance.
     *
     * @throws \PHPMailer\PHPMailer\Exception When the OAuth callback did
     *         not capture an authorized email and the admin must reconnect.
     *         wp_mail() catches the exception and fires wp_mail_failed so
     *         DeliveryErrorNotice can surface a dismissible admin notice.
     */
    public function configure( PHPMailer $phpmailer ): void {
        $access_token = $this->maybe_refresh_token();

        if ( empty( $access_token ) ) {
            return;
        }

        $email = $this->resolve_authorized_email();

        if ( '' === $email ) {
            // See OutlookMailer::configure() for the rationale. A wrong SASL
            // user= field against Gmail produces a 535 BadCredentials with
            // no diagnostic. Surface "reconnect required" via the standard
            // wp_mail_failed path instead of letting PHPMailer fall through
            // to the default mail() transport.
            throw new \PHPMailer\PHPMailer\Exception(
                esc_html__( 'Gmail OAuth is misconfigured: no authorized email captured. Please reconnect your Gmail account from NimblyMail &rarr; Connection.', 'nimblymail-smtp' )
            );
        }

        $phpmailer->isSMTP();
        $phpmailer->Host       = 'smtp.gmail.com';
        $phpmailer->Port       = 587;
        $phpmailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
        $phpmailer->SMTPAuth   = true;
        $phpmailer->AuthType   = 'XOAUTH2';

        $phpmailer->setOAuth(
            new GoogleOAuthTokenProvider( $email, $access_token )
        );
    }

    /**
     * Option key used as an atomic mutex for token refresh operations.
     *
     * Backed by `add_option` (which uses INSERT IGNORE under a unique index)
     * rather than a transient: object-cache flushes do not silently release
     * the lock, so two parallel sends cannot both call /refresh and have
     * the upstream rotate-then-invalidate one half of the pair.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const REFRESH_LOCK_OPTION = 'nimblymail_gmail_refresh_lock';

    /**
     * Lock TTL in seconds. A holder older than this is considered stale and
     * preempted by the next caller.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const REFRESH_LOCK_TTL = 30;

    /**
     * Group name in the Options store this mailer reads / writes.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const OPTION_GROUP = 'gmail';

    /**
     * Refresh the access token if expired or about to expire.
     *
     * Returns the cached token when still valid; otherwise atomically claims
     * a database-backed refresh lock, calls the signed `/refresh` endpoint
     * via {@see ProxyClient}, persists the rotated tokens, and releases the
     * lock. Concurrent callers see the lock and wait briefly for the
     * refresher to finish before re-reading.
     *
     * @since 1.0.0
     *
     * @return string The valid access token, or empty string on failure.
     */
    private function maybe_refresh_token(): string {
        $options       = Options::init();
        $access_token  = $options->get( self::OPTION_GROUP, 'access_token' );
        $refresh_token = $options->get( self::OPTION_GROUP, 'refresh_token' );
        $token_expiry  = (int) $options->get( self::OPTION_GROUP, 'token_expiry' );

        if ( empty( $access_token ) && empty( $refresh_token ) ) {
            return '';
        }

        if ( ! empty( $access_token ) && $token_expiry > ( time() + 60 ) ) {
            return $access_token;
        }

        if ( ! self::try_acquire_lock() ) {
            sleep( 1 );
            Options::reset();
            $options      = Options::init();
            $fresh_token  = $options->get( self::OPTION_GROUP, 'access_token' );
            $fresh_expiry = (int) $options->get( self::OPTION_GROUP, 'token_expiry' );

            if ( ! empty( $fresh_token ) && $fresh_expiry > ( time() + 60 ) ) {
                return $fresh_token;
            }

            return (string) $fresh_token;
        }

        try {
            return $this->do_refresh_token( $options );
        } finally {
            self::release_lock();
        }
    }

    /**
     * Atomically claim the refresh lock.
     *
     * Uses `add_option` (INSERT IGNORE on the options unique index) to
     * detect a held lock in a way that survives object-cache flushes. Stale
     * locks (older than {@see REFRESH_LOCK_TTL}) are preempted with a
     * guarded `UPDATE ... WHERE option_value = $stale_value` so the row is
     * only overwritten if it still holds the exact stale value this caller
     * read. Two callers racing the same stale lock cannot both win: the
     * first `UPDATE` changes `option_value`, so the second caller's `WHERE`
     * no longer matches and its `UPDATE` affects zero rows. A bare
     * `update_option()` (or a `delete_option()` + `add_option()` pair, which
     * still lets a second caller's delete silently remove a first caller's
     * freshly-won lock) both allow two callers to believe they hold the
     * lock; this compare-and-swap does not.
     *
     * @since 1.0.0
     * @since 1.0.3 Replaced the bare `update_option()` stale-preemption,
     *              and then the delete_option() + add_option() pair that
     *              replaced it, with a guarded `UPDATE ... WHERE
     *              option_value = $stale_value` compare-and-swap — the
     *              only construct that closes the race where two callers
     *              could both observe the same stale lock and both proceed.
     *
     * @return bool True when the lock was claimed.
     */
    private static function try_acquire_lock(): bool {
        $now = time();

        if ( add_option( self::REFRESH_LOCK_OPTION, (string) $now, '', false ) ) {
            return true;
        }

        $stale_value = get_option( self::REFRESH_LOCK_OPTION, 0 );
        $held_at     = (int) $stale_value;

        if ( 0 !== $held_at && ( $now - $held_at ) < self::REFRESH_LOCK_TTL ) {
            return false;
        }

        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $wpdb->options is internal; values bound via prepare(). Compare-and-swap needs the raw query: the options API has no conditional-update primitive.
        $updated = $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
                (string) $now,
                self::REFRESH_LOCK_OPTION,
                (string) $stale_value
            )
        );

        if ( 1 !== $updated ) {
            return false;
        }

        wp_cache_delete( self::REFRESH_LOCK_OPTION, 'options' );

        return true;
    }

    /**
     * Release the refresh lock.
     *
     * @since 1.0.0
     *
     * @return void
     */
    private static function release_lock(): void {
        delete_option( self::REFRESH_LOCK_OPTION );
    }

    /**
     * Perform the actual OAuth token refresh via the signed proxy endpoint.
     *
     * @since 1.0.0
     *
     * @param Options $options The options instance used to read the refresh token and persist the new access token.
     *
     * @return string The new access token, or empty string on failure.
     */
    private function do_refresh_token( Options $options ): string {
        $refresh_token = (string) $options->get( self::OPTION_GROUP, 'refresh_token' );

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

        $backend = BackendFactory::for_provider( 'google' );

        if ( ! $backend->is_provisioned() ) {
            // Hosted: pre-signed-refresh tokens cannot reach the new proxy endpoint.
            // BYOK: client_id / client_secret missing — user has not finished BYOK setup.
            // Either way the user must reconnect, so fail closed here.
            return '';
        }

        $result = $backend->refresh( $refresh_token );

        if ( ! $result['ok'] || empty( $result['data']['access_token'] ) || ! is_string( $result['data']['access_token'] ) ) {
            // Surface Google's diagnostic so admins can trace silent 535
            // failures (invalid_grant after refresh revoke, app deleted,
            // scope changed, etc.). Logged values are non-secret error
            // codes / descriptions — never the refresh_token.
            $data            = $result['data'];
            $error_code      = isset( $data['error'] ) && is_string( $data['error'] ) ? $data['error'] : '';
            $error_descr     = isset( $data['error_description'] ) && is_string( $data['error_description'] ) ? $data['error_description'] : '';
            $transport_error = isset( $result['error'] ) ? $result['error'] : '';
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
            error_log(
                sprintf(
                    '[NimblyMail Gmail] Token refresh failed: status=%d error=%s description=%s transport=%s. Admin may need to reconnect the Gmail account.',
                    $result['status'],
                    '' !== $error_code ? $error_code : '(none)',
                    '' !== $error_descr ? $error_descr : '(none)',
                    '' !== $transport_error ? $transport_error : '(none)'
                )
            );
            return '';
        }

        $new_access_token = (string) $result['data']['access_token'];
        $raw_expires_in   = $result['data']['expires_in'] ?? null;
        $expires_in       = is_numeric( $raw_expires_in ) ? min( max( (int) $raw_expires_in, 60 ), DAY_IN_SECONDS ) : 3600;
        $new_expiry       = time() + $expires_in;

        $persist = array(
            'access_token' => $new_access_token,
            'token_expiry' => $new_expiry,
        );

        // Google occasionally rotates refresh tokens — persist the new one so
        // the next refresh cycle uses the current value.
        if ( ! empty( $result['data']['refresh_token'] ) && is_string( $result['data']['refresh_token'] ) ) {
            $persist['refresh_token'] = (string) $result['data']['refresh_token'];
        }

        $options->set( array( self::OPTION_GROUP => $persist ) );

        return $new_access_token;
    }

    /**
     * Resolve the email address Google associates with the stored access token.
     *
     * Gmail's SMTP XOAUTH2 binding ties the bearer token to a specific Google
     * identity. The `user=` field of the SASL string MUST match that identity
     * or Google rejects the auth with a 535 BadCredentials error. We trust
     * `gmail.email`, which the OAuth callback always extracts from the OIDC
     * id_token's claims (or the userinfo endpoint via the hosted proxy).
     * We deliberately do NOT fall back to a live userinfo lookup nor to
     * `mail.from_email` / `admin_email` — substituting the wrong identity
     * poisons the SASL string and produces a confusing 535 BadCredentials
     * that admins find hard to diagnose. Returning empty lets the caller
     * (`configure()`) surface a clear "reconnect required" error instead.
     *
     * @since 1.0.0
     *
     * @return string Stored email, or empty when capture failed — caller
     *                surfaces a "reconnect required" error in that case.
     */
    private function resolve_authorized_email(): string {
        return (string) $this->get_option( 'email' );
    }

    /**
     * Get this mailer's deliverability audit requirements.
     *
     * Google manages DKIM signing for Gmail-routed mail; the user does not
     * publish a selector. The audit reports DKIM as Skipped via the empty
     * selectors array.
     *
     * @since 1.0.0
     *
     * @return array{spf_includes: string[], dkim_selectors: string[], docs_url: string}
     */
    public function get_audit_requirements(): array {
        return array(
            'spf_includes'   => array( '_spf.google.com' ),
            'dkim_selectors' => array(),
            'docs_url'       => 'https://support.google.com/a/answer/33786',
        );
    }

    /**
     * Get the connectivity endpoints for the Gmail OAuth mailer.
     *
     * Returns the Gmail SMTP host/port and the Google API endpoint used for
     * token validation to allow the HealthAuditor to verify outbound access.
     *
     * @since 1.0.0
     *
     * @return array<array<string, mixed>>
     */
    public function get_connectivity_endpoints(): array {
        return array(
            array(
                'type' => 'smtp',
                'host' => 'smtp.gmail.com',
                'port' => 587,
            ),
            array(
                'type' => 'api',
                'url'  => 'https://www.googleapis.com/oauth2/v2/userinfo',
            ),
        );
    }
}
