<?php
/**
 * Background queue runner.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Queue;

use NimblyMail\SMTP\Encryption;
use NimblyMail\SMTP\Log\Logger;
use NimblyMail\SMTP\Options;
use NimblyMail\SMTP\Processor;
use NimblyMail\SMTP\Sandbox\SandboxInterceptor;
use Throwable;

defined( 'ABSPATH' ) || exit;

/**
 * Class Runner
 *
 * Action Scheduler callback for nimblymail_process_queue_item. Claims a
 * queue row, decrypts its payload, re-invokes wp_mail() with a bypass
 * marker so the existing send pipeline runs unmodified, and records the
 * outcome on both the queue row and the originating log row.
 *
 * @since 1.0.0
 * @since 1.0.3 Delivery is now captured via an early wp_mail_succeeded
 *              listener so a hook throwing after a successful send cannot
 *              trigger a duplicate resend; the linked log row is explicitly
 *              reconciled to 'sent' in that case, since the throwing hook
 *              can also prevent Logger::mark_success() from running.
 */
class Runner {

    /**
     * Default initial backoff in seconds. Overridable via
     * `queue.initial_backoff_seconds` setting.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const DEFAULT_INITIAL_BACKOFF_SECONDS = 60;

    /**
     * Default maximum number of attempts. Overridable via
     * `queue.max_attempts` setting.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const DEFAULT_MAX_ATTEMPTS = 5;

    /**
     * Hard cap on attempt count to keep the exponential backoff bounded.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const MAX_ATTEMPTS_CAP = 10;

    /**
     * Register the Action Scheduler hook.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function init(): void {
        add_action( Manager::RUNNER_HOOK, array( $this, 'process' ), 10, 1 );
    }

    /**
     * Process a single queue row.
     *
     * Called by Action Scheduler with the queue row id as the only argument.
     * Performs an atomic claim, decrypts the payload, re-invokes wp_mail
     * via the existing send pipeline (with a bypass marker so this Runner
     * does not enqueue itself again), and records the outcome.
     *
     * @since 1.0.0
     * @since 1.0.3 A wp_mail_succeeded listener capturing delivery is now
     *              registered at the earliest priority so a later hook
     *              throwing after delivery cannot cause a duplicate send;
     *              the log row is reconciled to 'sent' in that case too.
     *
     * @param int $queue_id The queue row id supplied by Action Scheduler.
     *
     * @return void
     */
    public function process( int $queue_id ): void {
        if ( $queue_id <= 0 ) {
            return;
        }

        // Honour the pause toggle. Reschedule for a near-future retry so the
        // row gets picked up automatically once processing resumes.
        //
        // Note: pause + queue.enabled=false together create a soft hold —
        // already-pending rows keep getting rescheduled here until either
        // pause is turned off (then they drain) or an admin cancels them
        // from the Queue admin page. That is intentional: pause is the
        // explicit "freeze the worker" primitive, distinct from disable
        // which only stops new mail from entering the queue.
        if ( Options::init()->get( 'queue', 'pause_processing' ) ) {
            // Cheap existence check so we do not perpetually reschedule a
            // row that an admin has already cancelled while paused.
            $row = Model::find_one( $queue_id );
            if ( null === $row ) {
                return;
            }
            if ( function_exists( 'as_schedule_single_action' ) ) {
                as_schedule_single_action(
                    time() + MINUTE_IN_SECONDS,
                    Manager::RUNNER_HOOK,
                    array( $queue_id ),
                    Manager::AS_GROUP,
                    false,
                    (int) $row['priority']
                );
            }
            return;
        }

        if ( ! Model::claim( $queue_id ) ) {
            // Already claimed by another worker, deleted, or in a terminal
            // state. Nothing for us to do.
            return;
        }

        $row = Model::find_one( $queue_id );
        if ( null === $row ) {
            return;
        }

        $log_id = $this->find_linked_log_id( $queue_id );
        if ( $log_id > 0 ) {
            Logger::update_lifecycle_status( $log_id, 'processing' );
        }

        try {
            $atts = $this->decrypt_payload( (string) $row['payload'] );
        } catch ( Throwable $e ) {
            // Payload corrupt or salts changed — cannot recover. Permanent fail.
            $this->finalize_permanent_failure( $queue_id, $log_id, self::sanitize_error( 'Payload decrypt failed: ' . $e->getMessage() ) );
            return;
        }

        // If the admin enabled sandbox while this row was queued, do not
        // re-invoke wp_mail — that would bypass the sandbox interceptor (we
        // already mark Processor::mark_bypass below) and ship a message the
        // admin asked us not to deliver. Permanently fail so the row stops
        // bouncing between processing/pending until manual cancellation.
        if ( Options::init()->get( 'sandbox', 'enabled' ) ) {
            $this->finalize_permanent_failure( $queue_id, $log_id, 'Sandbox enabled after enqueue.' );
            return;
        }

        // "Do Not Send" blocks delivery globally. Re-invoking wp_mail() would
        // short-circuit at pre_wp_mail and bounce the row through retry /
        // permanent-failure, so finalise it as blocked (handled, not a failure)
        // and drop the row instead of re-sending.
        if ( Options::init()->get( 'general', 'do_not_send' ) ) {
            if ( $log_id > 0 ) {
                Logger::update_lifecycle_status( $log_id, 'blocked' );
            }
            AttachmentStager::cleanup( $queue_id );
            Model::delete( $queue_id );
            return;
        }

        Manager::set_runner_context( true );
        Logger::set_resume_log_id( $log_id );
        Logger::set_pending_queue_id( $queue_id );
        Processor::mark_bypass( $atts );

        // WP core fires wp_mail_succeeded right after PHPMailer::send()
        // succeeds but BEFORE wp_mail() returns, and core's own try/catch
        // only catches PHPMailer\Exception — so a Throwable from any
        // wp_mail_succeeded listener (including Processor::handle_success's
        // own nimblymail_mail_sent, which third-party add-ons may hook)
        // escapes wp_mail() uncaught and lands in our catch below with
        // $sent still false. Recording delivery here, at the earliest
        // possible priority, means a later listener throwing can no longer
        // retroactively turn an already-delivered message into a retry.
        $delivered      = false;
        $mark_delivered = static function () use ( &$delivered ): void {
            $delivered = true;
        };
        add_action( 'wp_mail_succeeded', $mark_delivered, -9999 );

        $sent       = false;
        $send_error = '';
        try {
            $sent = (bool) wp_mail(
                $atts['to'] ?? '',
                $atts['subject'] ?? '',
                $atts['message'] ?? '',
                $atts['headers'] ?? '',
                $atts['attachments'] ?? array()
            );
        } catch ( Throwable $e ) {
            $send_error = $e->getMessage();
        } finally {
            remove_action( 'wp_mail_succeeded', $mark_delivered, -9999 );

            // Reset Logger statics in finally so an exception thrown by any
            // hook on the inner wp_mail() does not leak the resume_log_id /
            // pending_queue_id into the next Action Scheduler batch item
            // running under the same PHP process.
            Manager::set_runner_context( false );
            Logger::set_resume_log_id( 0 );
            Logger::set_pending_queue_id( 0 );

            // Defensive: ensure the sandbox decision pinned to this send is
            // not carried into the next iteration in long-lived workers.
            SandboxInterceptor::consume_decision();
            SandboxInterceptor::consume_redirect_originals();
        }

        if ( $delivered && ! $sent ) {
            // The transport delivered the message; a hook that ran after
            // wp_mail_succeeded threw and stopped wp_mail() from returning
            // normally. The send itself did not fail, so honour the
            // delivery instead of retrying, but still surface the hook
            // failure since it is a real bug in whatever listener threw.
            $sent = true;
            if ( '' !== $send_error ) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log( '[nimblymail-smtp] A wp_mail_succeeded/nimblymail_mail_sent listener threw after a queued message was delivered (queue_id=' . $queue_id . '): ' . $send_error );
            }

            // Processor::handle_success() is registered on wp_mail_succeeded
            // before Logger::mark_success() (see Plugin.php), so a throwing
            // listener reached from handle_success() aborts the hook chain
            // before mark_success() ever runs, leaving the log row stuck on
            // 'processing'. Reconcile it explicitly here; if mark_success()
            // did run (a listener registered after it threw instead), this
            // is a harmless no-op re-write of the same 'sent' status.
            if ( $log_id > 0 ) {
                Logger::update_lifecycle_status( $log_id, 'sent' );
            }
        }

        if ( $sent ) {
            // In the normal case Logger::mark_success() (wp_mail_succeeded
            // callback) already flipped the log row to 'sent' with its own
            // duration tracking. The $delivered && ! $sent branch above
            // covers the case where a throwing hook prevented that from
            // happening — no further bookkeeping needed here either way.
            $this->finalize_success( $queue_id );
            return;
        }

        // Send failed. Decide retry vs permanent fail.
        $error    = self::sanitize_error( '' !== $send_error ? $send_error : 'wp_mail returned false' );
        $attempts = (int) $row['attempts'] + 1;

        if ( $attempts >= $this->max_attempts() ) {
            $this->finalize_permanent_failure( $queue_id, $log_id, $error );
            return;
        }

        $this->schedule_retry( $queue_id, $attempts, $error, (int) $row['priority'] );
    }

    /**
     * Scrub absolute filesystem paths and collapse the message length.
     *
     * Stored on the queue row's `last_error` column, which is searchable via
     * the REST API. Leaving raw exception text in place leaks the host's
     * filesystem layout (and sometimes upstream library internals) to anyone
     * with admin access. We replace `/abs/path/file.php` with `[plugin]/file.php`
     * for our own files and `[…]/file.ext` for everything else, then truncate.
     *
     * @since 1.0.0
     *
     * @param string $message Raw error message.
     *
     * @return string Scrubbed message.
     */
    private static function sanitize_error( string $message ): string {
        $plugin_dir = defined( 'NIMBLYMAIL_PATH' )
            ? rtrim( str_replace( '\\', '/', NIMBLYMAIL_PATH ), '/' )
            : '';

        if ( '' !== $plugin_dir ) {
            $message = str_replace( $plugin_dir, '[plugin]', str_replace( '\\', '/', $message ) );
        }

        $message = (string) preg_replace( '#(/[^\s:()]+/)+#', '[…]/', $message );

        if ( strlen( $message ) > 500 ) {
            $message = substr( $message, 0, 497 ) . '...';
        }

        return $message;
    }

    /**
     * Compute the next retry delay (seconds) for an attempt count.
     *
     * Doubles the configured initial backoff for each subsequent attempt
     * (exponential). Capped at 1 hour so a sustained outage does not push
     * retries days into the future.
     *
     * @since 1.0.0
     *
     * @param int $attempts Attempt count of the failure that just happened (1-based).
     *
     * @return int Seconds to wait before the next retry.
     */
    private function backoff_seconds( int $attempts ): int {
        $base = (int) Options::init()->get( 'queue', 'initial_backoff_seconds' );
        if ( $base <= 0 ) {
            $base = self::DEFAULT_INITIAL_BACKOFF_SECONDS;
        }

        $exponent = max( 0, $attempts - 1 );
        // Bound the exponent so 32-bit hosts cannot overflow.
        $exponent = min( $exponent, 12 );

        $delay = $base * ( 2 ** $exponent );
        return (int) min( $delay, HOUR_IN_SECONDS );
    }

    /**
     * Resolve the configured maximum attempt count, clamped to a sensible range.
     *
     * @since 1.0.0
     *
     * @return int
     */
    private function max_attempts(): int {
        $configured = (int) Options::init()->get( 'queue', 'max_attempts' );
        if ( $configured <= 0 ) {
            return self::DEFAULT_MAX_ATTEMPTS;
        }
        return min( $configured, self::MAX_ATTEMPTS_CAP );
    }

    /**
     * Schedule a retry attempt and update the queue row bookkeeping.
     *
     * @since 1.0.0
     *
     * @param int    $queue_id The queue row id.
     * @param int    $attempts The new attempt count (1-based).
     * @param string $error    The last error message.
     * @param int    $priority The row's priority tier (forwarded to AS so retries stay in the same lane).
     *
     * @return void
     */
    private function schedule_retry( int $queue_id, int $attempts, string $error, int $priority ): void {
        $delay        = $this->backoff_seconds( $attempts );
        $next_attempt = gmdate( 'Y-m-d H:i:s', time() + $delay );

        Model::mark_failed_retry( $queue_id, $attempts, $next_attempt, $error );

        if ( function_exists( 'as_schedule_single_action' ) ) {
            as_schedule_single_action(
                time() + $delay,
                Manager::RUNNER_HOOK,
                array( $queue_id ),
                Manager::AS_GROUP,
                false,
                $priority
            );
        }

        /**
         * Fires after a queued send fails and a retry has been scheduled.
         *
         * @since 1.0.0
         *
         * @param int    $queue_id  The queue row id.
         * @param int    $attempts  The new attempt count.
         * @param int    $delay_sec Seconds until the scheduled retry runs.
         * @param string $error     The last error message.
         */
        do_action( 'nimblymail_queue_item_failed', $queue_id, $attempts, $delay, $error );
    }

    /**
     * Finalise a successfully delivered queue row.
     *
     * Deletes the queue row and removes any staged attachments. The log
     * row was already updated to 'sent' by Logger::mark_success() during
     * the inner wp_mail() call.
     *
     * @since 1.0.0
     *
     * @param int $queue_id The queue row id.
     *
     * @return void
     */
    private function finalize_success( int $queue_id ): void {
        AttachmentStager::cleanup( $queue_id );
        Model::delete( $queue_id );

        /**
         * Fires after a queued message has been successfully delivered.
         *
         * @since 1.0.0
         *
         * @param int $queue_id The queue row id (the row itself is now deleted).
         */
        do_action( 'nimblymail_queue_item_sent', $queue_id );
    }

    /**
     * Finalise a row whose attempt budget is exhausted (or whose payload
     * could not be decrypted).
     *
     * @since 1.0.0
     *
     * @param int    $queue_id The queue row id.
     * @param int    $log_id   The originating log row id (0 if unknown).
     * @param string $error    The final error message.
     *
     * @return void
     */
    private function finalize_permanent_failure( int $queue_id, int $log_id, string $error ): void {
        Model::mark_failed_permanent( $queue_id, $error );
        if ( $log_id > 0 ) {
            Logger::update_lifecycle_status( $log_id, 'failed', null, $error );
        }
        AttachmentStager::cleanup( $queue_id );

        /**
         * Fires after a queued message is permanently abandoned.
         *
         * @since 1.0.0
         *
         * @param int    $queue_id The queue row id (still present, status='failed').
         * @param string $error    The final error message.
         */
        do_action( 'nimblymail_queue_item_permanently_failed', $queue_id, $error );
    }

    /**
     * Decrypt a queue payload and decode it into wp_mail() atts.
     *
     * @since 1.0.0
     *
     * @param string $encrypted The encrypted payload column value.
     *
     * @return array<string, mixed> Decoded payload (to, subject, message, headers, attachments).
     *
     * @throws \RuntimeException When decryption or JSON decoding fails.
     */
    private function decrypt_payload( string $encrypted ): array {
        $plain   = Encryption::decrypt( $encrypted );
        $decoded = json_decode( $plain, true );
        if ( ! is_array( $decoded ) ) {
            throw new \RuntimeException( 'Queue payload did not decode to an array.' );
        }
        return $decoded;
    }

    /**
     * Look up the log row id linked to a queue row.
     *
     * @since 1.0.0
     *
     * @param int $queue_id The queue row id.
     *
     * @return int Linked log row id, or 0 when not found.
     */
    private function find_linked_log_id( int $queue_id ): int {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $id = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT id FROM {$wpdb->prefix}nimblymail_logs WHERE queue_id = %d ORDER BY id DESC LIMIT 1",
                $queue_id
            )
        );

        return $id ? (int) $id : 0;
    }
}
