<?php
/**
 * REST API Queue Controller.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Rest;

use NimblyMail\SMTP\Log\Logger;
use NimblyMail\SMTP\Queue\AttachmentStager;
use NimblyMail\SMTP\Queue\Manager as QueueManager;
use NimblyMail\SMTP\Queue\Model;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Class QueueController
 *
 * Read and manage rows in the background queue: list with filters,
 * aggregate stats, manual retry, individual cancel, and bulk cancel.
 *
 * @since 1.0.0
 * @since 1.0.3 retry_item() now rejects rows currently 'processing'.
 */
class QueueController extends AbstractController {

    /**
     * Maximum number of rows touched by a single cancel-all request.
     *
     * Bounds the per-request work — at this size the bulk UPDATE on logs
     * stays a single round-trip and the inner Action-Scheduler unschedule
     * loop completes inside the typical PHP execution timeout. Callers
     * receive `more_remaining: true` in the response and can call again.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const CANCEL_ALL_BATCH_LIMIT = 500;

    /**
     * Register the REST routes.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function register_routes(): void {
        register_rest_route(
            self::NAMESPACE,
            '/queue',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_queue' ),
                    'permission_callback' => array( $this, 'permissions_check' ),
                    'args'                => array(
                        'page'     => array(
                            'type'              => 'integer',
                            'default'           => 1,
                            'sanitize_callback' => 'absint',
                        ),
                        'per_page' => array(
                            'type'              => 'integer',
                            'default'           => 20,
                            'sanitize_callback' => 'absint',
                        ),
                        'status'   => array(
                            'type'              => 'string',
                            'sanitize_callback' => 'sanitize_text_field',
                        ),
                        'priority' => array(
                            'type'              => 'integer',
                            'sanitize_callback' => 'absint',
                        ),
                        'search'   => array(
                            'type'              => 'string',
                            'sanitize_callback' => 'sanitize_text_field',
                        ),
                        'orderby'  => array(
                            'type'              => 'string',
                            'sanitize_callback' => 'sanitize_text_field',
                        ),
                        'order'    => array(
                            'type'              => 'string',
                            'sanitize_callback' => 'sanitize_text_field',
                        ),
                    ),
                ),
            )
        );

        register_rest_route(
            self::NAMESPACE,
            '/queue/stats',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_stats' ),
                    'permission_callback' => array( $this, 'permissions_check' ),
                ),
            )
        );

        register_rest_route(
            self::NAMESPACE,
            '/queue/(?P<id>\d+)',
            array(
                array(
                    'methods'             => WP_REST_Server::DELETABLE,
                    'callback'            => array( $this, 'cancel_item' ),
                    'permission_callback' => array( $this, 'permissions_check' ),
                    'args'                => array(
                        'id' => array(
                            'type'              => 'integer',
                            'required'          => true,
                            'sanitize_callback' => 'absint',
                        ),
                    ),
                ),
            )
        );

        register_rest_route(
            self::NAMESPACE,
            '/queue/(?P<id>\d+)/retry',
            array(
                array(
                    'methods'             => WP_REST_Server::CREATABLE,
                    'callback'            => array( $this, 'retry_item' ),
                    'permission_callback' => array( $this, 'permissions_check' ),
                    'args'                => array(
                        'id' => array(
                            'type'              => 'integer',
                            'required'          => true,
                            'sanitize_callback' => 'absint',
                        ),
                    ),
                ),
            )
        );

        register_rest_route(
            self::NAMESPACE,
            '/queue/cancel-all',
            array(
                array(
                    'methods'             => WP_REST_Server::CREATABLE,
                    'callback'            => array( $this, 'cancel_all' ),
                    'permission_callback' => array( $this, 'permissions_check' ),
                    'args'                => array(
                        'status' => array(
                            'type'              => 'string',
                            'sanitize_callback' => 'sanitize_text_field',
                        ),
                    ),
                ),
            )
        );
    }

    /**
     * List queue rows with filters and pagination.
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request The REST request.
     *
     * @return WP_REST_Response
     */
    public function get_queue( WP_REST_Request $request ): WP_REST_Response {
        $args = array(
            'page'     => $request->get_param( 'page' ) ? (int) $request->get_param( 'page' ) : 1,
            'per_page' => $request->get_param( 'per_page' ) ? (int) $request->get_param( 'per_page' ) : 20,
            'status'   => $request->get_param( 'status' ),
            'priority' => $request->get_param( 'priority' ),
            'search'   => $request->get_param( 'search' ),
            'orderby'  => $request->get_param( 'orderby' ),
            'order'    => $request->get_param( 'order' ),
        );

        $result = Model::find( $args );
        $total  = (int) $result['total'];
        $pages  = $args['per_page'] > 0 ? (int) ceil( $total / $args['per_page'] ) : 0;

        // Decorate items with their linked log row recipient/subject so the
        // queue UI can show them without a second round-trip per row.
        $items = $this->decorate_items( $result['items'] );

        $response = new WP_REST_Response( $items );
        $response->header( 'X-WP-Total', (string) $total );
        $response->header( 'X-WP-TotalPages', (string) $pages );

        return $response;
    }

    /**
     * Return aggregate counts grouped by status.
     *
     * @since 1.0.0
     *
     * @return WP_REST_Response
     */
    public function get_stats(): WP_REST_Response {
        return new WP_REST_Response( Model::count_by_status() );
    }

    /**
     * Cancel (delete) a single queue row and clean up its staged attachments.
     *
     * The linked log row, if any, is flipped to status='failed' with a
     * cancelled-by-admin error message so the audit trail is preserved.
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request The REST request.
     *
     * @return WP_REST_Response|WP_Error
     */
    public function cancel_item( WP_REST_Request $request ) {
        $id = (int) $request->get_param( 'id' );
        if ( $id <= 0 ) {
            return new WP_Error( 'invalid_id', __( 'Invalid queue id.', 'nimblymail-smtp' ), array( 'status' => 400 ) );
        }

        $row = Model::find_one( $id );
        if ( null === $row ) {
            return new WP_Error( 'not_found', __( 'Queue item not found.', 'nimblymail-smtp' ), array( 'status' => 404 ) );
        }

        $log_id = $this->find_linked_log_id( $id );
        if ( $log_id > 0 ) {
            Logger::update_lifecycle_status(
                $log_id,
                'failed',
                null,
                __( 'Cancelled by admin.', 'nimblymail-smtp' )
            );
        }

        // Drop the DB row first so a failed FS cleanup never leaves a
        // pending row pointing at missing staged paths.
        Model::delete( $id );
        AttachmentStager::cleanup( $id );

        if ( function_exists( 'as_unschedule_all_actions' ) ) {
            as_unschedule_all_actions(
                QueueManager::RUNNER_HOOK,
                array( $id ),
                QueueManager::AS_GROUP
            );
        }

        return new WP_REST_Response( array( 'success' => true ) );
    }

    /**
     * Force a queue row to be retried as soon as possible.
     *
     * Resets the next_attempt timestamp to "now" and schedules an
     * immediate Action Scheduler action so the runner picks it up on
     * the next dispatch.
     *
     * @since 1.0.0
     * @since 1.0.3 Reject retrying a row that is currently 'processing' — a
     *              live worker may be sending it, and resetting the status
     *              here would let a second worker claim and send it too.
     *              Enforced via Model::reset_for_retry()'s atomic guarded
     *              UPDATE rather than a separate read-then-write status
     *              check, which would leave a race window for a concurrent
     *              claim() between the read and the write.
     *
     * @param WP_REST_Request $request The REST request.
     *
     * @return WP_REST_Response|WP_Error
     */
    public function retry_item( WP_REST_Request $request ) {
        $rate_error = $this->check_rate_limit( 'queue_retry_rate', 30, 60 );
        if ( is_wp_error( $rate_error ) ) {
            return $rate_error;
        }

        $id = (int) $request->get_param( 'id' );
        if ( $id <= 0 ) {
            return new WP_Error( 'invalid_id', __( 'Invalid queue id.', 'nimblymail-smtp' ), array( 'status' => 400 ) );
        }

        $row = Model::find_one( $id );
        if ( null === $row ) {
            return new WP_Error( 'not_found', __( 'Queue item not found.', 'nimblymail-smtp' ), array( 'status' => 404 ) );
        }

        if ( ! Model::reset_for_retry( $id ) ) {
            return new WP_Error(
                'queue_item_processing',
                __( 'This item is currently being sent and cannot be retried yet.', 'nimblymail-smtp' ),
                array( 'status' => 409 )
            );
        }

        if ( function_exists( 'as_enqueue_async_action' ) ) {
            as_enqueue_async_action(
                QueueManager::RUNNER_HOOK,
                array( $id ),
                QueueManager::AS_GROUP
            );
        }

        return new WP_REST_Response( array( 'success' => true ) );
    }

    /**
     * Cancel every queue row, optionally filtered by status.
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request The REST request.
     *
     * @return WP_REST_Response|WP_Error
     */
    public function cancel_all( WP_REST_Request $request ) {
        $rate_error = $this->check_rate_limit( 'queue_cancel_all_rate', 5, 60 );
        if ( is_wp_error( $rate_error ) ) {
            return $rate_error;
        }

        $status = (string) $request->get_param( 'status' );

        $allowed_statuses = array( 'pending', 'processing', 'failed' );
        $where_status     = '' !== $status && in_array( $status, $allowed_statuses, true ) ? $status : null;

        global $wpdb;
        if ( null !== $where_status ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $wpdb->prefix is internal; user input bound via prepare().
            $ids = $wpdb->get_col(
                $wpdb->prepare(
                    "SELECT id FROM {$wpdb->prefix}nimblymail_queue WHERE status = %s LIMIT %d",
                    $where_status,
                    self::CANCEL_ALL_BATCH_LIMIT + 1
                )
            );
        } else {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $wpdb->prefix is internal; user input bound via prepare().
            $ids = $wpdb->get_col(
                $wpdb->prepare(
                    "SELECT id FROM {$wpdb->prefix}nimblymail_queue LIMIT %d",
                    self::CANCEL_ALL_BATCH_LIMIT + 1
                )
            );
        }

        $ids            = array_map( 'intval', (array) $ids );
        $more_remaining = count( $ids ) > self::CANCEL_ALL_BATCH_LIMIT;
        if ( $more_remaining ) {
            $ids = array_slice( $ids, 0, self::CANCEL_ALL_BATCH_LIMIT );
        }

        if ( empty( $ids ) ) {
            return new WP_REST_Response(
                array(
                    'success'        => true,
                    'cancelled'      => 0,
                    'more_remaining' => false,
                )
            );
        }

        // Single bulk UPDATE for the linked log rows beats N round-trips
        // when an admin nukes a 500-row backlog.
        $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
        $note         = __( 'Cancelled by admin (bulk).', 'nimblymail-smtp' );
        $bind         = array_merge( array( $note ), $ids );

        // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $placeholders is composed of fixed %d markers built from an int-cast count; user data is bound via prepare().
        $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$wpdb->prefix}nimblymail_logs SET status = 'failed', error_message = %s WHERE queue_id IN ({$placeholders})",
                $bind
            )
        );
        // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter

        foreach ( $ids as $id ) {
            AttachmentStager::cleanup( $id );

            if ( function_exists( 'as_unschedule_all_actions' ) ) {
                as_unschedule_all_actions(
                    QueueManager::RUNNER_HOOK,
                    array( $id ),
                    QueueManager::AS_GROUP
                );
            }
        }

        $deleted = Model::bulk_delete( $ids );

        return new WP_REST_Response(
            array(
                'success'        => true,
                'cancelled'      => $deleted,
                'more_remaining' => $more_remaining,
            )
        );
    }

    /**
     * Augment queue rows with the recipient and subject from their linked log row.
     *
     * @since 1.0.0
     *
     * @param array<int, array<string, mixed>> $rows Raw queue rows.
     *
     * @return array<int, array<string, mixed>>
     */
    private function decorate_items( array $rows ): array {
        if ( empty( $rows ) ) {
            return array();
        }

        $ids = array_map( 'intval', array_column( $rows, 'id' ) );
        if ( empty( $ids ) ) {
            return $rows;
        }

        global $wpdb;
        $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

        // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $placeholders is built from a fixed-format integer count and contains only %d markers; values are bound via prepare().
        $log_rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT queue_id, to_address, subject FROM {$wpdb->prefix}nimblymail_logs WHERE queue_id IN ({$placeholders})",
                $ids
            ),
            ARRAY_A
        );
        // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter

        $by_queue_id = array();
        foreach ( (array) $log_rows as $row ) {
            $by_queue_id[ (int) $row['queue_id'] ] = $row;
        }

        return array_map(
            static function ( array $row ) use ( $by_queue_id ): array {
                $qid            = (int) $row['id'];
                $row['to']      = $by_queue_id[ $qid ]['to_address'] ?? '';
                $row['subject'] = $by_queue_id[ $qid ]['subject'] ?? '';
                return $row;
            },
            $rows
        );
    }

    /**
     * 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;
    }
}
