<?php
/**
 * Outlook / Microsoft 365 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 OutlookMailer
 *
 * Sends email via Microsoft 365 / Outlook.com OAuth2 SMTP transport. Uses the
 * XOAUTH2 authentication mechanism with tokens minted through the NimblyMail
 * OAuth proxy. Mirrors GmailMailer one-for-one because the SMTP XOAUTH2 SASL
 * format is identical across providers — only the SMTP host and the token
 * refresh / userinfo endpoints differ.
 *
 * @since 1.0.0
 * @since 1.0.3 client_id marked encrypted for REST masking; refresh-lock stale-preemption made race-safe.
 */
class OutlookMailer extends AbstractMailer {

    /**
     * Option key used as an atomic mutex for token refresh operations.
     *
     * Backed by `add_option` (INSERT IGNORE on a unique index) rather than a
     * transient so object-cache flushes do not silently release the lock and
     * cause two parallel sends to both call /refresh — Microsoft rotates the
     * refresh token on every refresh, and a concurrent rotation invalidates
     * the older token, breaking the mailer until manual reconnect.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const REFRESH_LOCK_OPTION = 'nimblymail_outlook_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 = 'outlook';

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

    /**
     * {@inheritdoc}
     */
    public function get_title(): string {
        return __( 'Outlook / Microsoft 365', '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 Microsoft OAuth.', 'nimblymail-smtp' );
    }

    /**
     * Get the settings schema for this mailer as a section-shaped array.
     *
     * Identical shape to GmailMailer — a single OAuth authorize/disconnect
     * button — so the same FieldRenderer button branch handles both flows
     * without provider-specific UI code.
     *
     * @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, 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 Microsoft, 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'          => 'outlook_settings',
            'title'       => __( 'Outlook / Microsoft 365', 'nimblymail-smtp' ),
            'description' => __( 'Connect your Microsoft account to send emails via Outlook / Microsoft 365 SMTP.', 'nimblymail-smtp' ),
            'fields'      => array(
                array(
                    'id'       => 'byok_docs',
                    'group'    => 'outlook',
                    'title'    => __( 'Setup Guide', 'nimblymail-smtp' ),
                    'type'     => 'link',
                    'label'    => __( 'How to register your Microsoft Azure app', 'nimblymail-smtp' ),
                    'url'      => 'https://nimblymail.com/documentation/outlook/',
                    'external' => true,
                ),
                array(
                    'id'          => 'client_id',
                    'group'       => 'outlook',
                    'title'       => __( 'Client ID', 'nimblymail-smtp' ),
                    'type'        => 'text',
                    'encrypted'   => true,
                    'placeholder' => '00000000-0000-0000-0000-000000000000',
                    'description' => __( 'Application (client) ID from your Azure AD app registration.', 'nimblymail-smtp' ),
                ),
                array(
                    'id'          => 'client_secret',
                    'group'       => 'outlook',
                    'title'       => __( 'Client Secret', 'nimblymail-smtp' ),
                    'type'        => 'password',
                    'encrypted'   => true,
                    'description' => __( 'Client Secret value from your Azure AD app registration. Stored encrypted at rest.', 'nimblymail-smtp' ),
                ),
                array(
                    'id'          => 'tenant',
                    'group'       => 'outlook',
                    'title'       => __( 'Tenant', 'nimblymail-smtp' ),
                    'type'        => 'text',
                    'placeholder' => 'common',
                    'description' => __( 'Microsoft tenant scope. Use "common" (any account), "organizations" (Azure AD only), "consumers" (personal Microsoft accounts only), or a tenant GUID to restrict logins to a specific Azure AD tenant.', 'nimblymail-smtp' ),
                ),
                array(
                    'id'          => 'redirect_uri',
                    'group'       => 'outlook',
                    'title'       => __( 'Authorized Redirect URI', 'nimblymail-smtp' ),
                    'type'        => 'copyable-uri',
                    'value'       => Providers::default_redirect_uri(),
                    'description' => __( 'Copy this URL exactly and add it as a Web platform redirect URI in your Azure AD app registration. A mismatch returns AADSTS50011 on the next Connect attempt. Microsoft has no token-revoke endpoint, so Disconnect clears local state only — revoke the consent at https://myaccount.microsoft.com if needed.', 'nimblymail-smtp' ),
                ),
                array(
                    // Primary Connect button rendered after the BYOK credential
                    // fields. No byok_toggle, so it renders as a plain primary
                    // "Connect to Microsoft" CTA for the user's configured app.
                    'id'                => 'authorize_btn_byok',
                    'group'             => 'outlook',
                    'title'             => '',
                    'type'              => 'button',
                    'action'            => 'oauth/microsoft/authorize',
                    'disconnect_action' => 'oauth/microsoft/disconnect',
                    'status_field'      => true,
                    'is_authorized'     => $is_authorized,
                    'icon'              => NIMBLYMAIL_URL . 'assets/images/providers/microsoft.svg',
                    'label'             => $is_authorized
                        ? __( 'Connected to Microsoft', 'nimblymail-smtp' )
                        : __( 'Connect to Microsoft', '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' => 'outlook',
                ),
            ),
        );
    }

    /**
     * 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 Microsoft OAuth SMTP transport.
     *
     * Sets up PHPMailer to use smtp.office365.com:587 with XOAUTH2 over
     * STARTTLS. 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 ) {
            // Throw a PHPMailer exception so wp_mail() catches it and fires
            // wp_mail_failed → nimblymail_mail_failed → DeliveryErrorNotice
            // shows the admin a dismissible notice telling them to reconnect.
            // Silent return would let PHPMailer fall through to the default
            // mail() transport, masking the OAuth misconfiguration.
            throw new \PHPMailer\PHPMailer\Exception(
                esc_html__( 'Outlook OAuth is misconfigured: no authorized email captured. Please reconnect your Outlook account from NimblyMail &rarr; Connection.', 'nimblymail-smtp' )
            );
        }

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

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

    /**
     * Refresh the access token if expired or about to expire.
     *
     * Same lock-and-refresh semantics as GmailMailer: returns the cached token
     * when still valid, acquires a transient mutex when refresh is required,
     * and lets concurrent requests poll briefly for the freshly persisted
     * token rather than triggering a thundering-herd refresh against the
     * proxy.
     *
     * @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( 'microsoft' );

        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 Microsoft's diagnostic to the PHP error log so admins
            // can match a 535 / 401 send failure to its underlying cause
            // (refresh_token revoked, consent withdrawn, app deleted, tenant
            // mismatch, etc.). Microsoft returns AADSTS-prefixed error codes
            // with a human-readable `error_description` — both are non-secret
            // and safe to log. Avoid logging the refresh_token itself.
            $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 Outlook] Token refresh failed: status=%d error=%s description=%s transport=%s. Admin may need to reconnect the Outlook 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,
        );

        // Microsoft rotates refresh tokens on every refresh — persist the new
        // one when present so the next refresh cycle uses the current one.
        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 Microsoft associates with the stored access token.
     *
     * Microsoft's SMTP XOAUTH2 binding ties the bearer token to a specific
     * identity (UPN). The `user=` field of the SASL string MUST match that
     * identity or Microsoft rejects the auth. We trust `outlook.email`,
     * which the OAuth callback always extracts from the OIDC id_token's
     * claims (or the hosted proxy's /token response). We deliberately do
     * NOT fall back to Microsoft Graph /me (the SMTP-scoped token can't
     * call it — `User.Read` belongs to a different resource and Microsoft
     * rejects mixed-resource scope sets with AADSTS70011) nor to
     * `mail.from_email` / `admin_email` (substituting the wrong identity
     * produces a confusing 535 5.7.3 "Authentication unsuccessful").
     *
     * @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.
     *
     * Microsoft 365 / Exchange Online publishes a tenant-specific DKIM
     * selector (selector1, selector2 by default for custom domains), but
     * verifying it requires knowing the tenant's onmicrosoft.com domain
     * which we don't have at audit time. SPF on the other hand has a stable
     * include for every M365 tenant. DKIM is left empty so the audit reports
     * Skipped with the standard "configure at provider" hint.
     *
     * @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.protection.outlook.com' ),
            'dkim_selectors' => array(),
            'docs_url'       => 'https://learn.microsoft.com/microsoft-365/security/office-365-security/email-authentication-spf-configure',
        );
    }

    /**
     * Get the connectivity endpoints for the Outlook / Microsoft 365 mailer.
     *
     * @since 1.0.0
     *
     * @return array<array<string, mixed>>
     */
    public function get_connectivity_endpoints(): array {
        return array(
            array(
                'type' => 'smtp',
                'host' => 'smtp.office365.com',
                'port' => 587,
            ),
            array(
                'type' => 'api',
                'url'  => 'https://graph.microsoft.com/v1.0/me',
            ),
        );
    }
}
