<?php
/**
 * Plugin Name: AIEO — Dedup runaway recurring jobs
 * Description: AutomateWoo's `delete_old_logs` recurring job re-schedules itself on
 *   (nearly) every request without an effective "already scheduled?" guard, piling
 *   up thousands of pending duplicate `automatewoo/jobs/delete_old_logs/start`
 *   actions — all scheduled +4h out, so the queue runner never reaches them, the
 *   logs never get cleaned, and the table churn starves Action Scheduler and
 *   pressures PHP-FPM (502s). This collapses such SINGLETON recurring hooks to a
 *   single pending action at scheduling time, via Action Scheduler's
 *   `action_scheduler_pre_schedule_recurring_action` filter.
 *
 *   Unlike the emergency BLOCK list in 0-aieo-action-scheduler-core.php (which
 *   returns a fake id even when none exists, preventing the action entirely), this
 *   only DEDUPS: if one already exists it returns that id (skip the duplicate); if
 *   none exists it passes through so the FIRST one schedules and runs normally.
 *
 *   Why AutomateWoo's own dedup misses it: its "is it scheduled?" probe is short-
 *   circuited on admin-ajax by aieo-as-ajax-query-protection.php (heartbeat etc.),
 *   so it always sees "nothing scheduled" and re-creates. Guarding at the AS layer
 *   is robust regardless of which context triggers the schedule.
 *
 * Bundled by ai-eshop-optimizer (install/mu-plugins/).
 * @package AIEO
 */

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

if ( ! defined( 'AIEO_AS_DEDUP_RECURRING_HOOKS' ) ) {
	define(
		'AIEO_AS_DEDUP_RECURRING_HOOKS',
		array(
			// AutomateWoo recurring singletons. delete_old_logs was the original
			// runaway; the rest were found duplicating the same way (identical
			// args, one-per-heartbeat) on low-traffic sites e.g. eshop-optimizer.
			'automatewoo/jobs/delete_old_logs/start',
			'automatewoo/jobs/run_queued_workflows/start',
			'automatewoo/jobs/abandoned_carts/start',
			'automatewoo/jobs/products_goes_on_sale',
			'automatewoo/jobs/check_for_gmt_offset_change',
			'automatewoo/jobs/check_midnight_job',
			'automatewoo/jobs/clean_inactive_carts',
			'automatewoo/jobs/delete_expired_coupons/start',
			'automatewoo/jobs/delete_failed_queued_workflows/start',
			'automatewoo_midnight',
			'automatewoo_scheduled_notifications',
			// WPForms recurring singletons. Their as_has_scheduled_action() check
			// uses a `LIMIT 0, 1` query that the pre-2026-06-29 blocker rejected,
			// so they re-scheduled every admin-ajax heartbeat (30k+ each on
			// greytapartments). Unlike AutomateWoo these carry a UNIQUE
			// tasks_meta_id per copy, but the collapse keeps the earliest by hook
			// regardless of args, which is correct — they are singletons.
			'wpforms_process_purge_spam',
			'wpforms_process_forms_locator_scan',
		)
	);
}

// Priority -999998 runs just AFTER the emergency-blocker guard in
// 0-aieo-action-scheduler-core.php (-999999), which passes non-blocked hooks
// through unchanged ($pre stays null) — so this guard still gets to act.
add_filter( 'action_scheduler_pre_schedule_recurring_action', 'aieo_as_dedup_recurring_singleton', -999998, 4 );

/**
 * @param int|null $pre       Short-circuit value (null = not handled yet).
 * @param int      $timestamp First run timestamp.
 * @param int      $interval  Recurrence interval in seconds.
 * @param string   $hook      Action hook.
 * @return int|null Existing action id to dedup, or $pre to allow normal scheduling.
 */
function aieo_as_dedup_recurring_singleton( $pre, $timestamp, $interval, $hook ) {
	if ( null !== $pre ) { return $pre; }
	if ( ! in_array( $hook, AIEO_AS_DEDUP_RECURRING_HOOKS, true ) ) { return $pre; }

	global $wpdb;
	$table = $wpdb->prefix . 'actionscheduler_actions';
	$sql   = "SELECT action_id FROM {$table}
	          WHERE hook = '" . esc_sql( $hook ) . "'
	          AND status IN ('pending','in-progress')
	          ORDER BY action_id ASC LIMIT 1";

	// CRITICAL: run this existence probe on the RAW mysqli link, NOT $wpdb.
	// Both AS-ajax query blockers (aieo-as-ajax-query-protection.php and
	// 0-aieo-action-scheduler-core.php) hook the `query` filter and hide AS
	// reads on admin-ajax. That blinding is exactly what defeats AutomateWoo's
	// own is_scheduled() gate — so on every heartbeat it thinks nothing is
	// scheduled and re-creates. We MUST see the real table here, so we bypass
	// the `query` filter by querying the mysqli handle directly. ($hook is a
	// fixed internal constant; still esc_sql'd for safety.)
	$existing_id = 0;
	if ( isset( $wpdb->dbh ) && $wpdb->dbh instanceof mysqli ) {
		$res = mysqli_query( $wpdb->dbh, $sql );
		if ( $res instanceof mysqli_result ) {
			$row         = mysqli_fetch_row( $res );
			$existing_id = $row ? (int) $row[0] : 0;
			mysqli_free_result( $res );
		}
	} else {
		// Non-mysqli fallback (rare) — may be blocked, but better than nothing.
		$existing_id = (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB
	}

	// Dedup: existing → return its id so AS skips creating a duplicate.
	// None → return $pre (null) so the FIRST one schedules and runs normally.
	return $existing_id ? $existing_id : $pre;
}

/**
 * PRIMARY web-context fix. AutomateWoo hooks each recurring job's
 * schedule_recurring() to `admin_init` at priority 10, which fires on every
 * admin request — including admin-ajax heartbeat every ~15s. Because the
 * AS-ajax read blockers blind its is_scheduled() check there, it re-creates a
 * duplicate on each. We hook the SAME `admin_init` at priority 11 — i.e. the
 * very next thing in the same request — and collapse the surplus back to the
 * single earliest pending action. Runs on the raw mysqli link so it sees (and
 * cancels) the duplicates the blockers would otherwise hide.
 */
add_action( 'admin_init', 'aieo_as_collapse_dup_recurring_admin', 11 );

function aieo_as_collapse_dup_recurring_admin() {
	global $wpdb;
	if ( ! ( isset( $wpdb->dbh ) && $wpdb->dbh instanceof mysqli ) ) {
		return;
	}
	$table = $wpdb->prefix . 'actionscheduler_actions';
	foreach ( AIEO_AS_DEDUP_RECURRING_HOOKS as $hook ) {
		$h   = esc_sql( $hook );
		// Cancel every pending row except the earliest (MIN action_id). The
		// nested derived table is required so MySQL allows selecting from the
		// same table being updated.
		$sql = "UPDATE {$table} SET status = 'canceled'
		        WHERE hook = '{$h}' AND status = 'pending'
		        AND action_id > ( SELECT mid FROM ( SELECT MIN(action_id) mid FROM {$table}
		                          WHERE hook = '{$h}' AND status = 'pending' ) t )";
		@mysqli_query( $wpdb->dbh, $sql ); // phpcs:ignore WordPress.DB
	}
}

/**
 * Reactive backstop. The pre-schedule guard above only fires if AutomateWoo goes
 * through as_schedule_recurring_action() AND its existence read isn't blocked. To
 * be bulletproof regardless, collapse any accumulated duplicates to a single
 * pending action on every queue run (the system cron fires this ~1/min) — this
 * runs in CLI/cron context, where the AS-ajax read protection does NOT apply.
 */
add_action( 'action_scheduler_before_process_queue', 'aieo_as_collapse_dup_recurring' );

function aieo_as_collapse_dup_recurring() {
	global $wpdb;
	foreach ( AIEO_AS_DEDUP_RECURRING_HOOKS as $hook ) {
		$ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT action_id FROM {$wpdb->prefix}actionscheduler_actions
				 WHERE hook = %s AND status = 'pending' ORDER BY action_id ASC",
				$hook
			)
		);
		if ( is_array( $ids ) && count( $ids ) > 1 ) {
			array_shift( $ids ); // keep the earliest pending one
			$in = implode( ',', array_map( 'intval', $ids ) );
			// Direct cancel of the surplus (pending, unclaimed) duplicates.
			$wpdb->query( "UPDATE {$wpdb->prefix}actionscheduler_actions SET status = 'canceled' WHERE action_id IN ($in) AND status = 'pending'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $in is an int-cast list.
		}
	}
}
