<?php
/**
 * Index Queue (DB-backed)
 *
 * Persistent indexing queue for resilient indexing at scale.
 *
 * @package BoostBot
 * @since 1.13.0
 */

// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $this->table sanitized in constructor via sanitize_identifier(); array-args form unpacks params at runtime; values bind via prepare().
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Class BoostBot_Index_Queue
 *
 * Provides atomic dequeue/complete/fail operations over the index queue table.
 *
 * @since 1.13.0
 */
class BoostBot_Index_Queue {

    /**
     * @var wpdb
     */
    private $wpdb;

    /**
     * @var string
     */
    private $table;

    public function __construct() {
        global $wpdb;
        $this->wpdb  = $wpdb;
        $this->table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_index_queue' );
    }

    /**
     * Enqueue a content item.
     *
     * @param int    $content_id
     * @param string $content_type
     * @param int    $priority
     * @param int    $content_size
     * @param int    $extraction_level
     * @param int    $max_attempts
     * @return int|WP_Error Inserted row id
     */
    public function enqueue( int $content_id, string $content_type, int $priority = 5, int $content_size = 0, int $extraction_level = 5, int $max_attempts = 3 ) {
        $data = array(
            'content_id'        => $content_id,
            'content_type'      => $content_type,
            'priority'          => max( 0, min( 255, $priority ) ),
            'status'            => 'pending',
            'extraction_level'  => max( 1, min( 5, $extraction_level ) ),
            'attempts'          => 0,
            'max_attempts'      => max( 1, min( 10, $max_attempts ) ),
            'content_size'      => max( 0, (int) $content_size ),
            'created_at'        => current_time( 'mysql' ),
        );

        $formats = array( '%d', '%s', '%d', '%s', '%d', '%d', '%d', '%d', '%s' );

        // Use REPLACE to ensure uniqueness by (content_id, content_type).
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $ok = $this->wpdb->replace( $this->table, $data, $formats );
        if ( false === $ok ) {
            return new WP_Error( 'boostbot_index_queue_enqueue_failed', 'Failed to enqueue item.' );
        }

        return (int) $this->wpdb->insert_id;
    }

    /**
     * Enqueue many items.
     *
     * @param array<int,array{content_id:int,content_type:string,priority?:int,content_size?:int,extraction_level?:int,max_attempts?:int}> $items
     * @return array{inserted:int,errors:int}
     */
    public function enqueue_many( array $items ): array {
        $inserted = 0;
        $errors   = 0;
        foreach ( $items as $it ) {
            $res = $this->enqueue(
                (int) ( $it['content_id'] ?? 0 ),
                (string) ( $it['content_type'] ?? '' ),
                (int) ( $it['priority'] ?? 5 ),
                (int) ( $it['content_size'] ?? 0 ),
                (int) ( $it['extraction_level'] ?? 5 ),
                (int) ( $it['max_attempts'] ?? 3 )
            );
            if ( is_wp_error( $res ) ) {
                $errors++;
            } else {
                $inserted++;
            }
        }
        return array( 'inserted' => $inserted, 'errors' => $errors );
    }

    /**
     * Atomically claim the next pending item.
     *
     * @return array|null Row as associative array, or null if none.
     */
    public function dequeue(): ?array {
        $now = current_time( 'mysql' );

        // Atomic claim:
        // 1) Update ONE pending row to processing.
        // 2) Then select that same row using LAST_INSERT_ID trick.
        //
        // This relies on MySQL behavior and avoids race conditions without transactions.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $updated = $this->wpdb->query(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $this->wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "UPDATE {$this->table}
                 SET status = 'processing', started_at = %s, attempts = attempts + 1, id = LAST_INSERT_ID(id)
                 WHERE status = 'pending'
                 ORDER BY priority DESC, id ASC
                 LIMIT 1",
                $now
            )
        );

        if ( ! $updated ) {
            return null;
        }

        // Fetch the row we just claimed (session-safe via LAST_INSERT_ID()).
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $row = $this->wpdb->get_row( "SELECT * FROM {$this->table} WHERE id = LAST_INSERT_ID() LIMIT 1", ARRAY_A );

        return is_array( $row ) ? $row : null;
    }

    /**
     * Mark an item as completed.
     *
     * @param int   $id Queue row id
     * @param array $stats {memory_peak?:int,processing_time?:float}
     * @return bool
     */
    public function complete( int $id, array $stats = array() ): bool {
        $data = array(
            'status'        => 'completed',
            'completed_at'  => current_time( 'mysql' ),
        );
        $format = array( '%s', '%s' );

        if ( isset( $stats['memory_peak'] ) ) {
            $data['memory_peak'] = (int) $stats['memory_peak'];
            $format[]            = '%d';
        }
        if ( isset( $stats['processing_time'] ) ) {
            $data['processing_time'] = (float) $stats['processing_time'];
            $format[]                = '%f';
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        return false !== $this->wpdb->update( $this->table, $data, array( 'id' => $id ), $format, array( '%d' ) );
    }

    /**
     * Fail an item: either requeue (lower extraction) or mark failed.
     *
     * @param int    $id
     * @param string $error
     * @param int|null $next_extraction_level Optional downgrade level
     * @return bool
     */
    public function fail( int $id, string $error, ?int $next_extraction_level = null ): bool {
        $row = $this->get_by_id( $id );
        if ( ! $row ) {
            return false;
        }

        $attempts     = (int) ( $row['attempts'] ?? 0 );
        $max_attempts = (int) ( $row['max_attempts'] ?? 3 );

        $update = array(
            'last_error' => $error,
        );
        $format = array( '%s' );

        if ( $attempts >= $max_attempts ) {
            $update['status']       = 'failed';
            $update['completed_at'] = current_time( 'mysql' );
            $format[]               = '%s';
            $format[]               = '%s';
        } else {
            $update['status']     = 'pending';
            $update['started_at'] = null;
            $format[]             = '%s';
            $format[]             = '%s';

            if ( null !== $next_extraction_level ) {
                $update['extraction_level'] = max( 1, min( 5, (int) $next_extraction_level ) );
                $format[]                   = '%d';
            }
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        return false !== $this->wpdb->update( $this->table, $update, array( 'id' => $id ), $format, array( '%d' ) );
    }

    /**
     * Mark an item as skipped.
     *
     * @param int    $id
     * @param string $reason
     * @return bool
     */
    public function skip( int $id, string $reason ): bool {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        return false !== $this->wpdb->update(
            $this->table,
            array(
                'status'       => 'skipped',
                'last_error'   => $reason,
                'completed_at' => current_time( 'mysql' ),
            ),
            array( 'id' => $id ),
            array( '%s', '%s', '%s' ),
            array( '%d' )
        );
    }

    /**
     * Retry a failed/skipped item.
     *
     * @param int $id
     * @return bool
     */
    public function retry( int $id ): bool {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        return false !== $this->wpdb->update(
            $this->table,
            array(
                'status'       => 'pending',
                'attempts'     => 0,
                'last_error'   => null,
                'started_at'   => null,
                'completed_at' => null,
            ),
            array( 'id' => $id ),
            array( '%s', '%d', '%s', '%s', '%s' ),
            array( '%d' )
        );
    }

    /**
     * Get stuck items that have been processing longer than timeout.
     *
     * @param int $timeout_minutes
     * @return array<int,array>
     */
    public function get_stuck_items( int $timeout_minutes = 5 ): array {
        $timeout_minutes = max( 1, $timeout_minutes );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $this->wpdb->get_results(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $this->wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "SELECT * FROM {$this->table}
                 WHERE status = 'processing' AND started_at IS NOT NULL
                 AND started_at < (NOW() - INTERVAL %d MINUTE)
                 ORDER BY started_at ASC
                 LIMIT 100",
                $timeout_minutes
            ),
            ARRAY_A
        );

        return is_array( $rows ) ? $rows : array();
    }

    /**
     * Get queue statistics.
     *
     * @return array{pending:int,processing:int,completed:int,failed:int,skipped:int,total:int}
     */
    public function get_stats(): array {
        $out = array(
            'pending'    => 0,
            'processing' => 0,
            'completed'  => 0,
            'failed'     => 0,
            'skipped'    => 0,
            'total'      => 0,
        );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $this->wpdb->get_results(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            "SELECT status, COUNT(*) as c FROM {$this->table} GROUP BY status",
            ARRAY_A
        );
        if ( is_array( $rows ) ) {
            foreach ( $rows as $r ) {
                $status = (string) ( $r['status'] ?? '' );
                $c      = (int) ( $r['c'] ?? 0 );
                if ( isset( $out[ $status ] ) ) {
                    $out[ $status ] = $c;
                }
                $out['total'] += $c;
            }
        }

        return $out;
    }

    /**
     * Get queue rows (paged).
     *
     * @param int         $page
     * @param int         $per_page
     * @param string|null $status
     * @return array{items:array<int,array>,total:int}
     */
    public function get_queue( int $page = 1, int $per_page = 50, ?string $status = null ): array {
        $page     = max( 1, $page );
        $per_page = max( 1, min( 200, $per_page ) );
        $offset   = ( $page - 1 ) * $per_page;

        $where = '';
        $args  = array();
        if ( is_string( $status ) && '' !== $status ) {
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $where = "WHERE status = %s";
            $args[] = $status;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
        $total = (int) $this->wpdb->get_var(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $where ? $this->wpdb->prepare( "SELECT COUNT(*) FROM {$this->table} {$where}", $args ) : "SELECT COUNT(*) FROM {$this->table}"
        );

        if ( $where ) {
            $args2 = array_merge( $args, array( $per_page, $offset ) );
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $items = $this->wpdb->get_results(
                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
                $this->wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    "SELECT * FROM {$this->table} {$where} ORDER BY priority DESC, id ASC LIMIT %d OFFSET %d",
                    $args2
                ),
                ARRAY_A
            );
        } else {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $items = $this->wpdb->get_results(
                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
                $this->wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    "SELECT * FROM {$this->table} ORDER BY priority DESC, id ASC LIMIT %d OFFSET %d",
                    $per_page,
                    $offset
                ),
                ARRAY_A
            );
        }

        return array(
            'items' => is_array( $items ) ? $items : array(),
            'total' => $total,
        );
    }

    /**
     * Clear the queue.
     *
     * @return bool
     */
    public function clear(): bool {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        return false !== $this->wpdb->query( "TRUNCATE TABLE {$this->table}" );
    }

    /**
     * Get row by id.
     *
     * @param int $id
     * @return array|null
     */
    public function get_by_id( int $id ): ?array {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $row = $this->wpdb->get_row(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $this->wpdb->prepare( "SELECT * FROM {$this->table} WHERE id = %d LIMIT 1", $id ),
            ARRAY_A
        );
        return is_array( $row ) ? $row : null;
    }

    /**
     * Restrict a SQL identifier to [A-Za-z0-9_] characters.
     *
     * @param string $identifier Raw identifier.
     * @return string Identifier with disallowed characters removed.
     */
    private static function sanitize_identifier( $identifier ) {
        return preg_replace( '/[^A-Za-z0-9_]/', '', (string) $identifier );
    }
}

