<?php
/**
 * Plugin Name: AIEO — Action Scheduler AJAX query protection
 * Description: Blocks Action Scheduler SELECT queries on admin AJAX to keep
 *              badly-behaved plugins (AIOSEO, AutomateWoo, WC Admin, etc.)
 *              from triggering 90s+ delays during AJAX requests. INSERTs and
 *              scheduling are untouched — only reads are short-circuited.
 *
 * Bundled by ai-eshop-optimizer. Activation hook installs this file into
 * wp-content/mu-plugins/. Mirrors the historical om-disable-as-queries-ajax.php
 * (om-runtime mu-plugin) with three differences:
 *   - aieo_-prefixed function names + option keys
 *   - reads `aieo_as_ajax_protection_enabled` option (default ON) so an
 *     operator can disable the protection from the AS Cleanup admin page
 *     without renaming/deleting the file
 *   - skip-list now includes the AIEO DMM AS Cleanup AJAX action
 *
 * Why this exists, in one paragraph: Action Scheduler's query_actions() runs
 * a 9-query plan per call on `wp_actionscheduler_actions`. On a site with
 * 100+ pending actions (we routinely have 150+ thanks to AIOSEO scheduling
 * `aioseo_addons_refresh` / `aioseo_features_refresh` / `aioseo_license_check`
 * every page load and never cleaning them up), each call costs seconds.
 * AIOSEO + WC + AutomateWoo + FiboFilters all call `as_has_scheduled_action()`
 * during their boot, and admin AJAX (heartbeat, autosave, every editor save)
 * fires every few seconds — so without this filter, every AJAX adds up to
 * 90+ second delays. Short-circuit reads → cron/CLI processes the queue
 * normally → user-facing AJAX stays fast. The AS Cleanup module is the one
 * legitimate AJAX caller that needs to read AS state, so it's on the skip
 * list explicitly.
 *
 * @package AIEO
 */

if ( ! defined( 'ABSPATH' ) ) { exit; }


// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Reason: Read-only $_GET reads used to render admin notices/views; values are sanitized at read time.
// Hard kill-switch: operator can flip the option off from the AS Cleanup
// admin page (or via `wp option update aieo_as_ajax_protection_enabled 0`).
// Default ON (true) — protection is active out of the box.
if ( ! get_option( 'aieo_as_ajax_protection_enabled', true ) ) {
    return;
}

// Only on admin AJAX requests.
$aieo_as_is_admin_ajax = isset( $_SERVER['REQUEST_URI'] )
    && strpos( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 'admin-ajax.php' ) !== false;
if ( ! $aieo_as_is_admin_ajax ) {
    return;
}

// Never disable for CLI or Cron — those are the contexts that legitimately
// process queued actions, and blocking them would let the table grow forever.
if ( defined( 'WP_CLI' ) && WP_CLI ) { return; }
if ( defined( 'DOING_CRON' ) && DOING_CRON ) { return; }

// Resolve the current AJAX action so we can skip the protection for the
// few callers that legitimately need to read AS state from admin AJAX.
$aieo_as_ajax_action = isset( $_REQUEST['action'] )
    ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) )
    : '';

// AS's own async runner — must read pending actions to run them.
if ( $aieo_as_ajax_action === 'as_async_request_queue_runner' ) { return; }

// AIEO DMM AS Cleanup module — needs to query AS tables for stats + purge.
if ( $aieo_as_ajax_action === 'aieo_dmm_as_cleanup_step' ) { return; }

// Legacy mu-plugin AS Cleanup AJAX (retired 2026-05-12) — kept here for one
// release so any cached page that still posts to the old action name doesn't
// trip a 90s delay before the user reloads.
if ( $aieo_as_ajax_action === 'om_as_cleanup_step' ) { return; }

/**
 * Early SQL-level interception: rewrite Action Scheduler SELECTs to return
 * empty results. Cheap (one strpos + one regex per query) and runs at
 * priority 1 so it short-circuits before any downstream filter does work.
 */
add_filter( 'query', 'aieo_as_block_ajax_query', 1 );

function aieo_as_block_ajax_query( $query ) {
    // Only intercept SELECTs.
    if ( stripos( $query, 'SELECT' ) !== 0 ) {
        return $query;
    }

    global $wpdb;

    // wp_posts-based AS storage (pre-custom-tables migration path).
    if ( strpos( $query, "post_type = 'scheduled-action'" ) !== false
        || strpos( $query, "post_type='scheduled-action'" ) !== false
        || strpos( $query, 'post_type = "scheduled-action"' ) !== false
        || strpos( $query, 'post_type="scheduled-action"' ) !== false ) {
        return "SELECT ID FROM {$wpdb->posts} WHERE 1=0";
    }

    // Custom-tables AS storage (the modern path).
    if ( strpos( $query, 'actionscheduler_' ) !== false ) {
        // Allow cheap EXISTENCE / COUNT probes — these are what
        // as_has_scheduled_action() (a `SELECT count(...)`), as_next_scheduled_action()
        // (a `… LIMIT 1`, sometimes the offset form `LIMIT 0, 1`) and the dedup
        // system use to decide whether to schedule a new copy of a singleton hook.
        // Blocking them makes the check return "nothing scheduled" → DUPLICATE
        // actions pile up (this is exactly what caused AutomateWoo's
        // `delete_old_logs/start` runaway). Both forms are a single indexed lookup,
        // so they're cheap; only the expensive multi-row list query (the 9-query
        // plan, `LIMIT 0, N` with N>1) stays short-circuited.
        $normalized = trim( preg_replace( '/\s+/', ' ', $query ) );
        if ( preg_match( '/\bLIMIT\s+(?:\d+\s*,\s*)?1\b/i', $normalized )
            || preg_match( '/^SELECT\s+count\s*\(/i', $normalized ) ) {
            return $query;
        }
        return "SELECT 1 FROM {$wpdb->posts} WHERE 1=0";
    }

    return $query;
}

/**
 * Fallback for WP_Query-style reads against the scheduled-action post type
 * (anything that slips past the raw-SQL filter above).
 */
add_filter( 'pre_get_posts', 'aieo_as_short_circuit_query_ajax', 1 );

function aieo_as_short_circuit_query_ajax( $query ) {
    if ( ! isset( $query->query['post_type'] ) ) { return $query; }
    if ( $query->query['post_type'] === 'scheduled-action' ) {
        $query->set( 'post__in', array( 0 ) );
    }
    return $query;
}

// Disable AS's own queue runner on AJAX — cron/CLI are the only valid
// contexts for processing queued actions.
add_filter( 'action_scheduler_queue_runner_concurrent_batches', '__return_zero', 999 );
add_filter( 'action_scheduler_run_schedule',                    '__return_false', 999 );
add_filter( 'action_scheduler_allow_async_request_runner',      '__return_false', 999 );
