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

namespace NimblyMail\SMTP\Queue;

use NimblyMail\SMTP\Encryption;
use NimblyMail\SMTP\Log\Logger;
use NimblyMail\SMTP\Mailers\AbstractMailer;
use NimblyMail\SMTP\Options;
use NimblyMail\SMTP\Processor;
use Throwable;

defined( 'ABSPATH' ) || exit;

/**
 * Class Manager
 *
 * Bridges wp_mail() calls into the background queue. Hooks the
 * nimblymail_pre_send filter (Processor.php) and, when the queue is
 * enabled and the call is not bypassed, persists an encrypted payload
 * and schedules an Action Scheduler action for later delivery.
 *
 * Returning true from nimblymail_pre_send instructs Processor to
 * short-circuit wp_mail() so the caller sees true immediately.
 *
 * @since 1.0.0
 * @since 1.0.3 Corrected a misleading comment in enqueue() about next_attempt.
 */
class Manager {

    /**
     * Action Scheduler hook fired by the runner for each queued row.
     *
     * Public so QueueRunner (Phase 3) and any developer-facing tooling
     * can reference the same constant without hardcoding the string.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const RUNNER_HOOK = 'nimblymail_process_queue_item';

    /**
     * Action Scheduler group used for all queue-related actions.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const AS_GROUP = 'nimblymail-smtp';

    /**
     * Re-entry guard so the Manager never enqueues the runner's own
     * wp_mail() calls a second time.
     *
     * @since 1.0.0
     *
     * @var bool
     */
    private static bool $is_runner_context = false;

    /**
     * Register hooks.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function init(): void {
        add_filter( 'nimblymail_pre_send', array( $this, 'maybe_enqueue' ), 10, 3 );
    }

    /**
     * Toggle the runner-context flag.
     *
     * Phase 3's QueueRunner calls this with true around its wp_mail() call
     * so the Manager skips re-queueing the same message.
     *
     * @since 1.0.0
     *
     * @param bool $flag Whether the current call stack is the runner.
     *
     * @return void
     */
    public static function set_runner_context( bool $flag ): void {
        self::$is_runner_context = $flag;
    }

    /**
     * Decide whether the queue should accept this wp_mail() call.
     *
     * Filter callback for nimblymail_pre_send. Returns the original
     * $short_circuit value when the call should pass through to normal
     * delivery, or true when the message has been successfully enqueued.
     *
     * Bypass policy (any one of these → no enqueue):
     *  - queue_enabled setting is off
     *  - Processor::is_bypassed( $atts ) marker is set (re-submit by runner)
     *  - WP_CLI is defined (admin-driven sends should be synchronous)
     *  - the runner-context flag is on (defense in depth, complements bypass marker)
     *  - apply_filters( 'nimblymail_should_queue', true, $atts ) returns false
     *
     * @since 1.0.0
     *
     * @param bool                $short_circuit Current short-circuit decision (passed through unchanged on bypass).
     * @param array               $atts          The wp_mail() $atts array.
     * @param AbstractMailer|null $mailer        The mailer Processor selected.
     *
     * @return bool True to short-circuit (message enqueued); $short_circuit otherwise.
     */
    public function maybe_enqueue( $short_circuit, array $atts, $mailer = null ) {
        unset( $mailer );

        // Another handler already accepted responsibility.
        if ( true === $short_circuit ) {
            return $short_circuit;
        }

        if ( ! $this->should_queue( $atts ) ) {
            return $short_circuit;
        }

        $priority = PriorityResolver::resolve( $atts );

        try {
            $queue_id = $this->enqueue( $atts, $priority );
        } catch ( Throwable $e ) {
            // Never break the user's wp_mail() call because of a queue
            // failure. Log and fall through to synchronous delivery.
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
            error_log( '[nimblymail-smtp] enqueue failed; falling back to sync send: ' . $e->getMessage() );
            return $short_circuit;
        }

        if ( $queue_id <= 0 ) {
            return $short_circuit;
        }

        // Tell Logger which queue row this in-flight pending entry belongs to.
        Logger::set_pending_queue_id( $queue_id );

        // Schedule processing as soon as Action Scheduler can pick it up.
        // Pass through the resolved priority so AS dispatches HIGH-priority
        // items before NORMAL/BULK ones when multiple actions share the
        // same scheduled time.
        if ( function_exists( 'as_enqueue_async_action' ) ) {
            as_enqueue_async_action(
                self::RUNNER_HOOK,
                array( $queue_id ),
                self::AS_GROUP,
                false,
                $priority
            );
        }

        /**
         * Fires after a wp_mail() payload is successfully enqueued.
         *
         * @since 1.0.0
         *
         * @param int   $queue_id The new queue row id.
         * @param array $atts     The wp_mail() arguments (unmodified).
         */
        do_action( 'nimblymail_queue_item_enqueued', $queue_id, $atts );

        return true;
    }

    /**
     * Bypass policy.
     *
     * @since 1.0.0
     *
     * @param array $atts The wp_mail() arguments.
     *
     * @return bool True when the message should be enqueued.
     */
    private function should_queue( array $atts ): bool {
        if ( ! Options::init()->get( 'queue', 'enabled' ) ) {
            return false;
        }

        if ( self::$is_runner_context ) {
            return false;
        }

        if ( Processor::is_bypassed( $atts ) ) {
            return false;
        }

        if ( defined( 'WP_CLI' ) && WP_CLI ) {
            return false;
        }

        /**
         * Filters whether a wp_mail() call should be deferred to the queue.
         *
         * Returning false bypasses the queue for the current call only.
         * Useful for sites that want certain contexts (specific cron hooks,
         * specific recipients, REST endpoints, etc.) to remain synchronous.
         *
         * @since 1.0.0
         *
         * @param bool  $should_queue Default true once basic gates have passed.
         * @param array $atts         The wp_mail() arguments.
         *
         * @return bool
         */
        $should = apply_filters( 'nimblymail_should_queue', true, $atts );

        return (bool) $should;
    }

    /**
     * Persist a wp_mail() payload to the queue table.
     *
     * Stages attachments first so the queue row can store the staged paths;
     * encrypts the JSON payload at rest using the existing Encryption class.
     *
     * @since 1.0.0
     * @since 1.0.3 Corrected a misleading comment about the placeholder's
     *              next_attempt value; no behaviour change.
     *
     * @param array $atts     The wp_mail() arguments.
     * @param int   $priority The resolved priority tier for this message.
     *
     * @return int New queue row id, or 0 on failure.
     *
     * @throws \RuntimeException If encryption fails.
     */
    private function enqueue( array $atts, int $priority ): int {
        // Insert a placeholder so we have an id to namespace the staging
        // directory. next_attempt of 1970 here is bookkeeping only (claim()
        // does not filter on next_attempt); nothing can claim this row
        // regardless, since no Action Scheduler action references its id
        // until maybe_enqueue() schedules one after this method returns.
        $placeholder_id = Model::insert(
            array(
                'hash'         => '',
                'payload'      => '',
                'priority'     => $priority,
                'status'       => 'pending',
                'attempts'     => 0,
                'next_attempt' => '1970-01-01 00:00:00',
                'created_at'   => current_time( 'mysql', true ),
            )
        );

        if ( $placeholder_id <= 0 ) {
            return 0;
        }

        $original_attachments = $atts['attachments'] ?? array();
        $staged_paths         = AttachmentStager::stage( $placeholder_id, $original_attachments );

        $serialisable = array(
            'to'          => $atts['to'] ?? '',
            'subject'     => $atts['subject'] ?? '',
            'message'     => $atts['message'] ?? '',
            'headers'     => $atts['headers'] ?? '',
            'attachments' => $staged_paths,
        );

        $json = wp_json_encode( $serialisable );
        if ( false === $json ) {
            // Couldn't serialize — clean up and abort.
            AttachmentStager::cleanup( $placeholder_id );
            Model::delete( $placeholder_id );
            return 0;
        }

        $encrypted = Encryption::encrypt( $json );
        $hash      = self::compute_hash( $atts );

        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->update(
            Model::get_table(),
            array(
                'hash'         => $hash,
                'payload'      => $encrypted,
                'next_attempt' => current_time( 'mysql', true ),
            ),
            array( 'id' => $placeholder_id ),
            array( '%s', '%s', '%s' ),
            array( '%d' )
        );

        return $placeholder_id;
    }

    /**
     * Compute a stable dedupe hash from the message identity fields.
     *
     * Mirrors Processor::bypass_key() so the same {to, subject, message}
     * triple always hashes the same way. Hash is informational only —
     * the table does not enforce uniqueness on it; admins can use it to
     * spot duplicates in the queue UI later.
     *
     * @since 1.0.0
     *
     * @param array $atts The wp_mail() arguments.
     *
     * @return string 32-character md5 hash.
     */
    private static function compute_hash( 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
    }

    /**
     * Get aggregate queue statistics.
     *
     * Returns counts for pending, failed, and processing items plus the
     * timestamp of the oldest pending item. Status counts are sourced from
     * the cached Model::count_by_status() helper; the oldest-pending lookup
     * is uncached because it changes on every dispatch tick.
     *
     * @since 1.0.0
     *
     * @return array{pending: int, failed: int, processing: int, oldest_pending_at: ?string}
     */
    public function get_stats(): array {
        $counts = Model::count_by_status();

        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Hardcoded table name from $wpdb->prefix.
        $oldest = $wpdb->get_var(
            "SELECT created_at FROM {$wpdb->prefix}nimblymail_queue WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1"
        );

        return array(
            'pending'           => $counts['pending'],
            'failed'            => $counts['failed'],
            'processing'        => $counts['processing'],
            'oldest_pending_at' => null !== $oldest ? (string) $oldest : null,
        );
    }
}
