<?php
/**
 * Main email Processor.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP;

use NimblyMail\SMTP\Mailers\AbstractMailer;
use NimblyMail\SMTP\Mailers\DefaultMailer;
use NimblyMail\SMTP\Mailers\SmtpMailer;
use NimblyMail\SMTP\Mailers\GmailMailer;
use NimblyMail\SMTP\Mailers\OutlookMailer;
use NimblyMail\SMTP\Mailers\SesMailer;
use NimblyMail\SMTP\Mailers\ResendMailer;
use NimblyMail\SMTP\Mailers\SendLayerMailer;
use NimblyMail\SMTP\Mailers\BrevoMailer;
use NimblyMail\SMTP\Mailers\ZeptoMailMailer;
use NimblyMail\SMTP\Log\Logger;
use WP_Error;
use PHPMailer\PHPMailer\PHPMailer;

defined( 'ABSPATH' ) || exit;

/**
 * Class Processor
 *
 * Hooks into wp_mail and phpmailer_init.
 *
 * @since 1.0.0 Added debug log credential redaction and From header injection prevention.
 * @since 1.0.0 Hardened Do Not Send to short-circuit wp_mail via pre_wp_mail filter.
 * @since 1.0.0 Added nimblymail_active_mailer, nimblymail_pre_send, and nimblymail_mail_sent hooks.
 *
 * @phpstan-import-type NimblyMailLogPayload from \NimblyMail\SMTP\Contracts\LogPayload
 */
class Processor {

    /**
     * Active mailer instance.
     *
     * @since 1.0.0
     *
     * @var AbstractMailer|null
     */
    private ?AbstractMailer $active_mailer = null;

    /**
     * PHPMailer instance available during the current phpmailer_init call.
     *
     * Populated before nimblymail_active_mailer fires so handlers can
     * inspect the outgoing message (To, Subject, etc.) for routing decisions.
     *
     * @since 1.0.0
     *
     * @var PHPMailer|null
     */
    private ?PHPMailer $current_phpmailer = null;

    /**
     * Optional context array passed to nimblymail_active_mailer.
     *
     * Keys: to, subject, headers — populated on a best-effort basis.
     * Missing keys are absent rather than null.
     *
     * @since 1.0.0
     *
     * @var array
     */
    private array $current_context = array();

    /**
     * The mailer resolved (post-filter) for the current send.
     *
     * Set in configure_transport() after nimblymail_active_mailer fires so
     * handle_success() can reference it without re-running the filter.
     *
     * @since 1.0.0
     *
     * @var AbstractMailer|null
     */
    private ?AbstractMailer $resolved_mailer = null;

    /**
     * Re-entry depth counter for nimblymail_active_mailer.
     *
     * Guards against infinite loops when a Pro handler calls wp_mail() from
     * inside its own nimblymail_active_mailer callback. Capped at 2.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private static int $active_mailer_filter_depth = 0;

    /**
     * Bypass markers for nimblymail_pre_send.
     *
     * Keys are md5 hashes derived from {to, subject, message}. Handlers that
     * enqueue a message and later re-submit it via wp_mail() must call
     * Processor::mark_bypass() so the second pass skips the short-circuit logic.
     *
     * @since 1.0.0
     *
     * @var array<string, bool>
     */
    private static array $pre_send_bypass = array();

    /**
     * Whether nimblymail_pre_send requested a short-circuit for the current send.
     *
     * Set to true by the wp_mail filter (priority 5) when a handler returns true
     * from nimblymail_pre_send. Consumed and cleared by the pre_wp_mail handler
     * (priority 3) which returns true to WordPress to stop PHPMailer execution.
     *
     * @since 1.0.0
     *
     * @var bool
     */
    private static bool $pending_short_circuit = false;

    /**
     * Initialize hooks.
     *
     * @since 1.0.0 Added pre_wp_mail filter for Do Not Send short-circuit.
     * @since 1.0.0 Added nimblymail_pre_send hook at wp_mail priority 5.
     *
     * @return void
     */
    public function init(): void {
        add_filter( 'pre_wp_mail', array( $this, 'maybe_block_email' ), 1 );
        add_filter( 'pre_wp_mail', array( $this, 'maybe_short_circuit_send' ), 3 );
        add_filter( 'wp_mail', array( $this, 'pre_send_filter' ), 5 );
        add_filter( 'wp_mail', array( $this, 'filter_wp_mail' ), PHP_INT_MAX );
        add_action( 'phpmailer_init', array( $this, 'configure_transport' ), 999 );
        add_action( 'wp_mail_failed', array( $this, 'handle_failure' ) );
        add_action( 'wp_mail_succeeded', array( $this, 'handle_success' ) );
    }

    /**
     * Short-circuit wp_mail when Do Not Send is active.
     *
     * Returning a non-null value from the `pre_wp_mail` filter prevents
     * wp_mail() from executing entirely — no PHPMailer instantiation,
     * no SMTP connection, nothing leaves the server.
     *
     * @since 1.0.0 Added.
     *
     * @param null|bool $pre_wp_mail Short-circuit return value. Null to continue sending.
     *
     * @return null|bool Null to allow sending, false to block.
     */
    public function maybe_block_email( $pre_wp_mail ) {
        if ( Options::init()->get( 'general', 'do_not_send' ) ) {
            return false;
        }

        return $pre_wp_mail;
    }

    /**
     * Execute the short-circuit decision made by nimblymail_pre_send.
     *
     * This pre_wp_mail handler (priority 3) runs after maybe_block_email (priority 1)
     * but before WordPress processes the send. If nimblymail_pre_send requested a
     * short-circuit via the wp_mail filter (priority 5), this method returns true so
     * wp_mail() reports success without invoking PHPMailer.
     *
     * The static flag is cleared here so the next wp_mail() call starts fresh.
     *
     * @since 1.0.0
     *
     * @param null|bool $pre_wp_mail Current pre_wp_mail value.
     *
     * @return null|bool True to short-circuit the send, or the original value.
     */
    public function maybe_short_circuit_send( $pre_wp_mail ) {
        if ( self::$pending_short_circuit ) {
            self::$pending_short_circuit = false;
            return true;
        }
        return $pre_wp_mail;
    }

    /**
     * Get all registered mailers.
     *
     * @since 1.0.0
     *
     * @return array<string, AbstractMailer>
     */
    public function get_mailers(): array {
        $mailers = array(
            'php'       => new DefaultMailer(),
            'smtp'      => new SmtpMailer(),
            'gmail'     => new GmailMailer(),
            'outlook'   => new OutlookMailer(),
            'ses'       => new SesMailer(),
            'resend'    => new ResendMailer(),
            'sendlayer' => new SendLayerMailer(),
            'brevo'     => new BrevoMailer(),
            'zeptomail' => new ZeptoMailMailer(),
        );

        return apply_filters( 'nimblymail_mailers', $mailers );
    }

    /**
     * Get the currently active mailer based on options.
     *
     * Fires the `nimblymail_active_mailer` filter with re-entry protection so
     * Pro add-ons can override the mailer per-send without mutating the persisted
     * `mail.mailer` option. The filter is capped at depth 2 to prevent infinite
     * loops when a handler calls wp_mail() from inside its callback.
     *
     * If the filter returns a value that is not an AbstractMailer instance the
     * Processor logs a warning via error_log() and falls back to the original
     * mailer — no fatal errors.
     *
     * @since 1.0.0
     * @since 1.0.2 Removed a static-analysis suppression that is no longer needed.
     *
     * @return AbstractMailer
     */
    public function get_active_mailer(): AbstractMailer {
        if ( null === $this->active_mailer ) {
            $slug                = Options::init()->get( 'mail', 'mailer' );
            $mailers             = $this->get_mailers();
            $this->active_mailer = $mailers[ $slug ] ?? current( $mailers );
        }

        $mailer = $this->active_mailer;

        if ( self::$active_mailer_filter_depth < 2 ) {
            ++self::$active_mailer_filter_depth;

            /**
             * Filters the mailer selected for the current send.
             *
             * Runs every time NimblyMail needs to decide which AbstractMailer
             * will handle a pending wp_mail() call. Pro add-ons attach handlers
             * here to implement rule-based routing or backup retry.
             *
             * Re-entry is capped at depth 2 (one primary call, one retry). On
             * the second re-entry the filter is skipped and the original mailer
             * is used to prevent infinite loops.
             *
             * @since 1.0.0
             *
             * @param AbstractMailer  $mailer     The mailer selected from the persisted `mail.mailer` option.
             * @param PHPMailer|null  $phpmailer  The PHPMailer instance if available, else null.
             * @param array           $context    Optional context: { to, subject, headers }.
             *                                   Populated on a best-effort basis; missing keys are absent.
             *
             * @return AbstractMailer
             */
            $filtered = apply_filters( 'nimblymail_active_mailer', $mailer, $this->current_phpmailer, $this->current_context );

            --self::$active_mailer_filter_depth;

            if ( $filtered instanceof AbstractMailer ) {
                $mailer = $filtered;
            } else {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log( '[nimblymail-smtp] nimblymail_active_mailer returned a non-AbstractMailer value; falling back to the configured mailer.' );
            }
        }

        return $mailer;
    }

    /**
     * Early wp_mail filter — fires nimblymail_pre_send so Pro add-ons can
     * short-circuit delivery (e.g. for rate limiting / queuing).
     *
     * When a handler returns true from nimblymail_pre_send, this method sets
     * a static flag ($pending_short_circuit) that is consumed by the
     * maybe_short_circuit_send() pre_wp_mail handler (priority 3) which runs
     * after this filter completes. That handler returns true to WordPress,
     * causing wp_mail() to short-circuit before PHPMailer is invoked.
     *
     * Bypass markers set via Processor::mark_bypass() cause this method to
     * pass through unchanged so that a re-submitted (previously queued) message
     * is delivered normally.
     *
     * @since 1.0.0
     *
     * @param array $atts The wp_mail() $atts array.
     *
     * @return array Unchanged $atts (always — the short-circuit is signalled via static flag).
     */
    public function pre_send_filter( array $atts ): array {
        // Check for a bypass marker left by mark_bypass() — a queued message
        // being re-submitted for normal delivery. Clear the marker and let it through.
        if ( self::is_bypassed( $atts ) ) {
            self::clear_bypass( $atts );
            return $atts;
        }

        $mailer = $this->get_active_mailer();

        /**
         * Filters whether NimblyMail should short-circuit a pending send.
         *
         * Returning true signals that the filter has accepted responsibility
         * for the message (e.g. queued it for later delivery). NimblyMail then
         * returns true to the caller of wp_mail() without invoking PHPMailer
         * or the active mailer.
         *
         * WARNING: returning true without delivering or persisting the message
         * silently drops the email. Handlers MUST log the deferral and schedule
         * a re-submission via wp_mail() + Processor::mark_bypass() before
         * returning true.
         *
         * @since 1.0.0
         *
         * @param bool           $short_circuit Default false — allow normal delivery.
         * @param array          $atts          The wp_mail() $atts array (to, subject, message, headers, attachments).
         * @param AbstractMailer $mailer        The currently-selected active mailer.
         *
         * @return bool
         */
        $short_circuit = apply_filters( 'nimblymail_pre_send', false, $atts, $mailer );

        if ( true === $short_circuit ) {
            if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log( '[nimblymail-smtp] nimblymail_pre_send short-circuited delivery for: ' . ( is_array( $atts['to'] ) ? implode( ', ', $atts['to'] ) : ( $atts['to'] ?? '' ) ) );
            }

            self::$pending_short_circuit = true;
        }

        return $atts;
    }

    /**
     * Mark a set of wp_mail() atts for bypass on their next pass through
     * pre_send_filter().
     *
     * Rate-limiting or queuing handlers call this before re-submitting a
     * previously deferred message so that the second wp_mail() call is
     * delivered normally and is not short-circuited again.
     *
     * The bypass key is an md5 hash of { to, subject, message } and is
     * cleared automatically after the next pre_send_filter() pass.
     *
     * @since 1.0.0
     *
     * @param array $atts The wp_mail() $atts to bypass on next send.
     *
     * @return void
     */
    public static function mark_bypass( array $atts ): void {
        $key                           = self::bypass_key( $atts );
        self::$pre_send_bypass[ $key ] = true;
    }

    /**
     * Check whether a set of atts has a bypass marker set.
     *
     * @since 1.0.0
     *
     * @param array $atts The wp_mail() $atts.
     *
     * @return bool
     */
    public static function is_bypassed( array $atts ): bool {
        return ! empty( self::$pre_send_bypass[ self::bypass_key( $atts ) ] );
    }

    /**
     * Clear the bypass marker for a set of atts.
     *
     * @since 1.0.0
     *
     * @param array $atts The wp_mail() $atts.
     *
     * @return void
     */
    public static function clear_bypass( array $atts ): void {
        $key = self::bypass_key( $atts );
        unset( self::$pre_send_bypass[ $key ] );
    }

    /**
     * Derive a stable cache key from the message identity fields.
     *
     * @since 1.0.0
     *
     * @param array $atts The wp_mail() $atts.
     *
     * @return string
     */
    private static function bypass_key( array $atts ): string {
        $to      = is_array( $atts['to'] ?? '' ) ? implode( ',', $atts['to'] ) : ( $atts['to'] ?? '' );
        $subject = $atts['subject'] ?? '';
        $message = $atts['message'] ?? '';
        return md5( serialize( array( $to, $subject, $message ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
    }

    /**
     * Filter wp_mail arguments to inject From overrides.
     *
     * @since 1.0.0
     *
     * @param array $args The wp_mail arguments.
     *
     * @return array
     */
    public function filter_wp_mail( array $args ): array {
        $options = Options::init();

        if ( $options->get( 'mail', 'from_email_force' ) ) {
            $from_email = $options->get( 'mail', 'from_email' );
            if ( ! empty( $from_email ) ) {
                $from_name = $options->get( 'mail', 'from_name' );
                // Fall back to original name if force name is disabled but from email is forced.
                if ( ! $options->get( 'mail', 'from_name_force' ) ) {
                    $from_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
                }

                $args['headers'] = $this->replace_from_header(
                    $args['headers'] ?? array(),
                    $from_email,
                    $from_name
                );
            }
        }

        return $args;
    }

    /**
     * Configure PHPMailer on init.
     *
     * @since 1.0.0
     *
     * @param PHPMailer $phpmailer The PHPMailer object pass by reference natively in WP hook.
     *
     * @return void
     */
    public function configure_transport( PHPMailer $phpmailer ): void {
        $options = Options::init();

        // Populate context so nimblymail_active_mailer handlers can route by recipient/subject.
        $this->current_phpmailer = $phpmailer;
        $to_addresses            = $phpmailer->getToAddresses();
        $to_list                 = implode(
            ', ',
            array_map(
                function ( array $addr ): string {
                    return $addr[0];
                },
                $to_addresses
            )
        );
        $this->current_context   = array(
            'to'      => $to_list,
            'subject' => $phpmailer->Subject,
            'headers' => $phpmailer->getCustomHeaders(),
        );

        $mailer                  = $this->get_active_mailer();
        $this->resolved_mailer   = $mailer;
        $this->current_phpmailer = null;
        $this->current_context   = array();

        // Only configure if ready.
        if ( $mailer->is_ready() ) {
            $mailer->configure( $phpmailer );

            // API-based mailers (e.g. Resend) may replace the global $phpmailer
            // with a custom subclass. Re-read the global so subsequent property
            // assignments target the correct instance.
            if ( $GLOBALS['phpmailer'] !== $phpmailer ) {
                $phpmailer = $GLOBALS['phpmailer']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
            }
        }

        if ( $options->get( 'mail', 'from_email_force' ) ) {
            $from = $options->get( 'mail', 'from_email' );
            if ( ! empty( $from ) ) {
                $phpmailer->From   = $from;
                $phpmailer->Sender = $from;
            }
        }

        if ( $options->get( 'mail', 'from_name_force' ) ) {
            $name = $options->get( 'mail', 'from_name' );
            if ( ! empty( $name ) ) {
                $phpmailer->FromName = $name;
            }
        }

        if ( $options->get( 'mail', 'return_path' ) ) {
            $phpmailer->Sender = $phpmailer->From;
        } else {
            $custom_return_path = $options->get( 'mail', 'return_path_email' );
            if ( ! empty( $custom_return_path ) && is_email( $custom_return_path ) ) {
                $phpmailer->Sender = $custom_return_path;
            }
        }

        // Optionally enable debug mode capture.
        if ( $options->get( 'general', 'debug_mode' ) ) {
            $phpmailer->SMTPDebug   = 2; // SMTP::DEBUG_CLIENT.
            $phpmailer->Debugoutput = function ( $str ) {
                // Redact credentials before storing in the database.
                Logger::append_debug_log( self::sanitize_debug_log( $str ) );
            };
        }
    }

    /**
     * Handle a successful wp_mail send.
     *
     * Fired by the wp_mail_succeeded action (added in WordPress 5.9). Fires
     * the nimblymail_mail_sent action with a normalised payload matching the
     * NimblyMailLogPayload typedef in src/Contracts/LogPayload.php.
     *
     * @since 1.0.0
     *
     * @param array $mail_data The wp_mail arguments passed to the succeeded action.
     *
     * @return void
     */
    public function handle_success( array $mail_data ): void {
        $mailer = $this->resolved_mailer ?? $this->get_active_mailer();

        $to = '';
        if ( isset( $mail_data['to'] ) ) {
            $to = is_array( $mail_data['to'] ) ? implode( ', ', $mail_data['to'] ) : $mail_data['to'];
        }

        $message    = $mail_data['message'] ?? '';
        $size_bytes = strlen( $message );

        $log_data = array(
            'id'         => Logger::get_current_log_id(),
            'to'         => $to,
            'subject'    => mb_substr( $mail_data['subject'] ?? '', 0, 255 ),
            'mailer'     => $mailer->get_slug(),
            'sent_at'    => time(),
            'size_bytes' => $size_bytes,
            'source'     => Logger::get_current_source(),
        );

        /**
         * Fires after a mail is successfully sent.
         *
         * Provides consumers a normalised payload matching what Logger writes
         * to the DB, so analytics and rollups do not need a second query.
         *
         * @since 1.0.0
         *
         * @param array          $log_data Normalised payload (see NimblyMailLogPayload typedef):
         *                                 - id         int    Log row id (0 if logging disabled).
         *                                 - to         string Recipients, comma-separated.
         *                                 - subject    string Subject line.
         *                                 - mailer     string The mailer slug that handled the send.
         *                                 - sent_at    int    Unix timestamp.
         *                                 - size_bytes int    Approximate message size.
         *                                 - source     string Calling context identifier.
         * @param AbstractMailer $mailer   The mailer that handled the send.
         */
        do_action( 'nimblymail_mail_sent', $log_data, $mailer );
    }

    /**
     * Redact credentials and tokens from SMTP debug log output.
     *
     * Strips Bearer tokens, AWS access keys, XOAUTH2 base64 strings,
     * and SMTP AUTH PLAIN/LOGIN credentials to prevent credential
     * leakage in stored or returned debug logs.
     *
     * The two XOAUTH2 patterns are not primary and backup — each is the only
     * defense for a real send shape the other can't reach. A longer token,
     * as long-lived Microsoft tokens typically are, is sent as a bare "AUTH
     * XOAUTH2" command followed by the token alone on the next line, which
     * carries no "AUTH XOAUTH2" text; only the base64 prefix pattern catches
     * that line. The command-line pattern's own irreplaceable case is
     * narrower: any XOAUTH2 payload that isn't shaped like the well-formed
     * "user=..." SASL blob the prefix pattern expects — a well-formed short
     * token on one line, the common case, would in fact still be caught by
     * the prefix pattern alone. Removing the prefix pattern reopens a live
     * long-token leak; removing the command-line pattern narrows what's
     * covered rather than reopening the exact bug this fixed.
     *
     * @since 1.0.0
     * @since 1.0.0 Broadened Bearer token pattern to match all non-whitespace token formats.
     * @since 1.0.4 Widened the XOAUTH2 base64 prefix pattern, which previously matched only
     *              when the account's email address happened to encode to one specific
     *              leading byte and so missed the majority of real accounts, and added a
     *              pattern that redacts the whole AUTH XOAUTH2 command line.
     *
     * @param string $log The raw debug log string.
     *
     * @return string The redacted debug log string.
     */
    public static function sanitize_debug_log( string $log ): string {
        $patterns = array(
            '/Bearer\s+\S+/i',
            '/AKIA[A-Z0-9]{16}/',
            '/AUTH XOAUTH2[^\S\r\n]+\S+/i',
            '/dXNlcj[0-3][A-Za-z0-9+\/=]+/',
            '/AUTH PLAIN\s+[A-Za-z0-9+\/=]+/i',
            '/AUTH LOGIN\s+[A-Za-z0-9+\/=]+/i',
        );

        $replacements = array(
            'Bearer [REDACTED]',
            'AKIA...[REDACTED]',
            'AUTH XOAUTH2 [REDACTED]',
            '[REDACTED-XOAUTH2]',
            'AUTH PLAIN [REDACTED]',
            'AUTH LOGIN [REDACTED]',
        );

        return preg_replace( $patterns, $replacements, $log );
    }

    /**
     * Handle wp_mail failure.
     *
     * @since 1.0.0
     *
     * @param WP_Error $error The wp_mail error object.
     *
     * @return void
     */
    public function handle_failure( WP_Error $error ): void {
        do_action( 'nimblymail_mail_failed', $error );
    }

    /**
     * Replace or add a From header.
     *
     * Sanitizes name and email to prevent header injection via embedded
     * newlines or malformed values.
     *
     * @since 1.0.0 Added newline stripping and input sanitization for header injection prevention.
     *
     * @param string|array $headers Headers array or string.
     * @param string       $email   From email.
     * @param string       $name    From name.
     *
     * @return array
     */
    private function replace_from_header( $headers, string $email, string $name ): array {
        // Strip newlines and sanitize to prevent header injection.
        $email = sanitize_email( str_replace( array( "\r", "\n" ), '', $email ) );
        $name  = sanitize_text_field( str_replace( array( "\r", "\n" ), '', $name ) );

        if ( is_string( $headers ) ) {
            $headers = explode( "\n", str_replace( "\r\n", "\n", $headers ) );
        }
        // Strip empty headers and any existing 'from:' headers.
        $headers   = array_filter(
            $headers,
            function ( $h ) {
                $h = trim( $h );
                return ! empty( $h ) && stripos( $h, 'from:' ) !== 0;
            }
        );
        $headers[] = sprintf( 'From: %s <%s>', $name, $email );
        return array_values( $headers );
    }
}
