<?php
/**
 * Global background job manager.
 *
 * Provides a module-agnostic queue backed by ActionScheduler so any module
 * can register a handler and queue jobs without owning the DB table, REST
 * routes, or AS hook.
 *
 * @package JWP_STK
 */

namespace JWP_STK\Jobs;

defined( 'ABSPATH' ) || exit;

/**
 * Job_Manager singleton.
 *
 * Usage:
 *   // In Plugin::register() — before modules boot:
 *   Job_Manager::get_instance()->init();
 *
 *   // In a module's init():
 *   Job_Manager::get_instance()->register_handler( 'my-module', [ $this, 'handle_job' ] );
 *
 * Handler signature:
 *   function handle_job( array $job, callable $update ): void
 *     $job     — DB row as assoc array; $job['payload'] is already JSON-decoded.
 *     $update  — callable( int $progress, ?int $total_rows = null ): void
 *
 * After the handler returns normally the Job_Manager marks the job completed.
 * On uncaught exception it marks the job failed.
 */
class Job_Manager {

	/**
	 * Singleton instance.
	 *
	 * @var Job_Manager|null
	 */
	private static $instance;

	/**
	 * Registered module handlers.
	 * module_slug => callable( array $job, callable $update ): void
	 *
	 * @var callable[]
	 */
	private array $handlers = array();

	/**
	 * Private constructor — use get_instance().
	 */
	private function __construct() {}

	/**
	 * Get singleton instance.
	 *
	 * @return static
	 */
	public static function get_instance(): static {
		if ( null === self::$instance ) {
			self::$instance = new static();
		}
		return self::$instance;
	}

	// ─── Initialisation ───────────────────────────────────────────────────────

	/**
	 * Bind WordPress hooks. Called once from Plugin::register().
	 *
	 * @return void
	 */
	public function init(): void {
		add_action( 'init', array( $this, 'maybe_create_table' ) );
		add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
		add_action( 'jwp_stk_process_job', array( $this, 'process_job' ), 10, 1 );
	}

	// ─── Handler Registry ─────────────────────────────────────────────────────

	/**
	 * Register a processing handler for a module.
	 *
	 * @param string   $module  Module slug.
	 * @param callable $handler Handler: function( array $job, callable $update ): void.
	 * @return void
	 */
	public function register_handler( string $module, callable $handler ): void {
		$this->handlers[ $module ] = $handler;
	}

	// ─── Public API ───────────────────────────────────────────────────────────

	/**
	 * Insert a job row and enqueue an ActionScheduler async action.
	 *
	 * @param string $module   Module slug.
	 * @param string $type     Job type within the module (module-defined, e.g. 'orders').
	 * @param array  $payload  Arbitrary JSON-serialisable data passed to the handler.
	 * @param int    $user_id  WordPress user ID that owns the job.
	 * @return string|\WP_Error Job UUID on success, WP_Error on failure.
	 */
	public function queue_job( string $module, string $type, array $payload, int $user_id ) {
		global $wpdb;

		$table  = $this->get_table();
		$job_id = wp_generate_uuid4();
		$now    = gmdate( 'Y-m-d H:i:s' );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->insert(
			$table,
			array(
				'job_id'     => $job_id,
				'module'     => $module,
				'type'       => $type,
				'user_id'    => $user_id,
				'payload'    => wp_json_encode( $payload ),
				'status'     => 'pending',
				'created_at' => $now,
			),
			array( '%s', '%s', '%s', '%d', '%s', '%s', '%s' )
		);

		if ( ! $wpdb->insert_id ) {
			return new \WP_Error(
				'jwp_stk_job_create_failed',
				__( 'Failed to create job.', 'jetix-store-toolkit' ),
				array( 'status' => 500 )
			);
		}

		as_enqueue_async_action( 'jwp_stk_process_job', array( 'job_id' => $job_id ), 'jwp-stk' );

		return $job_id;
	}

	/**
	 * Check whether a job has been cancelled (for use inside a handler).
	 *
	 * @param string $job_id Job UUID.
	 * @return bool
	 */
	public function is_cancelled( string $job_id ): bool {
		global $wpdb;
		$table = $this->get_table();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$status = $wpdb->get_var(
			$wpdb->prepare( "SELECT status FROM $table WHERE job_id = %s", $job_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		);
		return 'cancelled' === $status;
	}

	/**
	 * Update progress (and optionally total_rows) for a job.
	 * Safe to call from any chained AS action.
	 *
	 * @param string   $job_id     Job UUID.
	 * @param int      $progress   0–100.
	 * @param int|null $total_rows Row count, if known.
	 * @return void
	 */
	public function update_progress( string $job_id, int $progress, ?int $total_rows = null ): void {
		global $wpdb;
		$fields  = array( 'progress' => $progress );
		$formats = array( '%d' );
		if ( null !== $total_rows ) {
			$fields['total_rows'] = $total_rows;
			$formats[]            = '%d';
		}
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update( $this->get_table(), $fields, array( 'job_id' => $job_id ), $formats, array( '%s' ) );
	}

	/**
	 * Mark a job as completed. Call from the final step of a chained job.
	 *
	 * @param string $job_id    Job UUID.
	 * @param string $started_at  ISO datetime when the job was created (for duration calc).
	 * @return void
	 */
	public function complete_job( string $job_id, string $started_at ): void {
		global $wpdb;
		$duration = max( 0, time() - strtotime( $started_at ) );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update(
			$this->get_table(),
			array(
				'status'           => 'completed',
				'progress'         => 100,
				'completed_at'     => gmdate( 'Y-m-d H:i:s' ),
				'duration_seconds' => $duration,
			),
			array( 'job_id' => $job_id ),
			array( '%s', '%d', '%s', '%d' ),
			array( '%s' )
		);
	}

	/**
	 * Mark a job as failed. Call from any chained step on error.
	 *
	 * @param string $job_id  Job UUID.
	 * @param string $message Human-readable error.
	 * @return void
	 */
	public function fail_job( string $job_id, string $message ): void {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update(
			$this->get_table(),
			array(
				'status'        => 'failed',
				'error_message' => substr( $message, 0, 1000 ),
			),
			array( 'job_id' => $job_id ),
			array( '%s', '%s' ),
			array( '%s' )
		);
	}

	// ─── DB ───────────────────────────────────────────────────────────────────

	/**
	 * Return the fully-qualified jobs table name.
	 *
	 * @return string
	 */
	public function get_table(): string {
		global $wpdb;
		return $wpdb->prefix . 'jwp_stk_jobs';
	}

	/**
	 * Create the jobs DB table (version-gated).
	 *
	 * Pure background-queue schema — no module-specific columns.
	 *
	 * @return void
	 */
	public function maybe_create_table(): void {
		$version_key = 'jwp_stk_jobs_db_version';
		$target      = '1.0';

		if ( get_option( $version_key, '' ) === $target ) {
			return;
		}

		global $wpdb;
		$table           = $this->get_table();
		$charset_collate = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE $table (
			id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			job_id varchar(36) NOT NULL,
			module varchar(50) NOT NULL DEFAULT '',
			type varchar(100) NOT NULL DEFAULT '',
			user_id bigint(20) UNSIGNED NOT NULL DEFAULT 0,
			payload longtext NOT NULL DEFAULT '',
			status varchar(20) NOT NULL DEFAULT 'pending',
			progress tinyint(3) UNSIGNED NOT NULL DEFAULT 0,
			total_rows int(11) NOT NULL DEFAULT 0,
			created_at datetime NOT NULL,
			completed_at datetime DEFAULT NULL,
			duration_seconds int(11) DEFAULT NULL,
			error_message text DEFAULT NULL,
			PRIMARY KEY  (id),
			UNIQUE KEY job_id (job_id),
			KEY module_user (module, user_id),
			KEY status (status)
		) $charset_collate;";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );

		update_option( $version_key, $target );
	}

	// ─── ActionScheduler Handler ──────────────────────────────────────────────

	/**
	 * Dispatch a queued job to its registered module handler.
	 *
	 * Called by ActionScheduler on the 'jwp_stk_process_job' hook.
	 *
	 * @param string $job_id Job UUID.
	 * @return void
	 */
	public function process_job( string $job_id ): void {
		global $wpdb;
		$table = $this->get_table();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$row = $wpdb->get_row(
			$wpdb->prepare( "SELECT * FROM $table WHERE job_id = %s", $job_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		);

		if ( ! $row || 'pending' !== $row->status ) {
			return; // Already cancelled, processed, or missing.
		}

		$handler = $this->handlers[ $row->module ] ?? null;

		if ( ! is_callable( $handler ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->update(
				$table,
				array(
					'status'        => 'failed',
					'error_message' => sprintf( 'No handler registered for module: %s', $row->module ),
				),
				array( 'job_id' => $job_id ),
				array( '%s', '%s' ),
				array( '%s' )
			);
			return;
		}

		// Mark as processing.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update(
			$table,
			array(
				'status'   => 'processing',
				'progress' => 10,
			),
			array( 'job_id' => $job_id ),
			array( '%s', '%d' ),
			array( '%s' )
		);

		// Build the job array passed to the handler. Payload is pre-decoded.
		$job             = (array) $row;
		$decoded_payload = json_decode( $row->payload, true );
		$job['payload']  = null !== $decoded_payload ? $decoded_payload : array();

		// Progress callback for the handler.
		$update = function ( int $progress, ?int $total_rows = null ) use ( $wpdb, $table, $job_id ) {
			$fields  = array( 'progress' => $progress );
			$formats = array( '%d' );
			if ( null !== $total_rows ) {
				$fields['total_rows'] = $total_rows;
				$formats[]            = '%d';
			}
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->update( $table, $fields, array( 'job_id' => $job_id ), $formats, array( '%s' ) );
		};

		try {
			call_user_func( $handler, $job, $update );

			// Re-check: handler may have detected cancellation and returned early.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$current_status = $wpdb->get_var(
				$wpdb->prepare( "SELECT status FROM $table WHERE job_id = %s", $job_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			);
			if ( 'cancelled' === $current_status ) {
				return;
			}

			$started_at = strtotime( $row->created_at );
			$duration   = max( 0, time() - $started_at );
			$completed  = gmdate( 'Y-m-d H:i:s' );

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->update(
				$table,
				array(
					'status'           => 'completed',
					'progress'         => 100,
					'completed_at'     => $completed,
					'duration_seconds' => $duration,
				),
				array( 'job_id' => $job_id ),
				array( '%s', '%d', '%s', '%d' ),
				array( '%s' )
			);

		} catch ( JobChainedException $e ) {
			// Handler chained to another AS action — leave the job as-is.
			return;

		} catch ( \Exception $e ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->update(
				$table,
				array(
					'status'        => 'failed',
					'error_message' => substr( $e->getMessage(), 0, 1000 ),
				),
				array( 'job_id' => $job_id ),
				array( '%s', '%s' ),
				array( '%s' )
			);
		}
	}

	// ─── REST Routes ──────────────────────────────────────────────────────────

	/**
	 * Register global job-management REST routes under /jwp-stk/v1.
	 *
	 * @return void
	 */
	public function register_rest_routes(): void {
		$id_param = array(
			'id' => array(
				'required'          => true,
				'type'              => 'string',
				'sanitize_callback' => 'sanitize_text_field',
			),
		);

		// List jobs for the current user (optional ?module=, ?page=, ?per_page=).
		register_rest_route(
			'jwp-stk/v1',
			'/jobs',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'rest_list_jobs' ),
					'permission_callback' => array( $this, 'check_permission' ),
					'args'                => array(
						'module'   => array(
							'required'          => false,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_key',
						),
						'page'     => array(
							'required'          => false,
							'type'              => 'integer',
							'default'           => 1,
							'minimum'           => 1,
							'sanitize_callback' => 'absint',
						),
						'per_page' => array(
							'required'          => false,
							'type'              => 'integer',
							'default'           => 10,
							'minimum'           => 1,
							'maximum'           => 100,
							'sanitize_callback' => 'absint',
						),
					),
				),
				// Bulk-delete all non-processing jobs for a module.
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'rest_delete_all_jobs' ),
					'permission_callback' => array( $this, 'check_permission' ),
					'args'                => array(
						'module' => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_key',
						),
					),
				),
			)
		);

		// Cancel / delete a single job.
		register_rest_route(
			'jwp-stk/v1',
			'/jobs/(?P<id>[a-zA-Z0-9_-]+)',
			array(
				'methods'             => \WP_REST_Server::DELETABLE,
				'callback'            => array( $this, 'rest_cancel_job' ),
				'permission_callback' => array( $this, 'check_permission' ),
				'args'                => $id_param,
			)
		);
	}

	/**
	 * Permission callback — requires manage_woocommerce.
	 *
	 * @return bool
	 */
	public function check_permission(): bool {
		return current_user_can( 'manage_woocommerce' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
	}

	/**
	 * Format a DB row into a REST response array.
	 *
	 * @param object $row DB row.
	 * @return array
	 */
	private function format_job( $row ): array {
		return array(
			'job_id'           => $row->job_id,
			'module'           => $row->module,
			'type'             => $row->type,
			'payload'          => null !== json_decode( $row->payload, true ) ? json_decode( $row->payload, true ) : array(),
			'status'           => $row->status,
			'progress'         => (int) $row->progress,
			'total_rows'       => (int) $row->total_rows,
			'created_at'       => $row->created_at,
			'completed_at'     => $row->completed_at,
			'duration_seconds' => null !== $row->duration_seconds ? (int) $row->duration_seconds : null,
			'error_message'    => $row->error_message,
		);
	}

	/**
	 * List jobs for the current user (paginated).
	 *
	 * @param \WP_REST_Request $request Request object.
	 * @return \WP_REST_Response
	 */
	public function rest_list_jobs( \WP_REST_Request $request ): \WP_REST_Response {
		global $wpdb;
		$table    = $this->get_table();
		$user_id  = get_current_user_id();
		$module   = $request->get_param( 'module' );
		$page     = max( 1, (int) $request->get_param( 'page' ) );
		$per_page = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ) );
		$offset   = ( $page - 1 ) * $per_page;

		if ( $module ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$total = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM $table WHERE user_id = %d AND module = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					$user_id,
					$module
				)
			);
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnsupportedPlaceholder, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"SELECT * FROM $table WHERE user_id = %d AND module = %s ORDER BY created_at DESC LIMIT %d OFFSET %d",
					$user_id,
					$module,
					$per_page,
					$offset
				)
			);
		} else {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$total = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM $table WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					$user_id
				)
			);
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnsupportedPlaceholder, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"SELECT * FROM $table WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d",
					$user_id,
					$per_page,
					$offset
				)
			);
		}

		$jobs        = array_map( array( $this, 'format_job' ), null !== $rows ? $rows : array() );
		$total_pages = (int) ceil( $total / $per_page );

		$response = new \WP_REST_Response( $jobs, 200 );
		$response->header( 'X-WP-Total', (string) $total );
		$response->header( 'X-WP-TotalPages', (string) $total_pages );
		return $response;
	}

	/**
	 * Bulk-delete all deletable jobs for a module (skips processing jobs).
	 *
	 * Processing jobs are marked cancelled; all others have their row + file removed.
	 *
	 * @param \WP_REST_Request $request Request object.
	 * @return \WP_REST_Response
	 */
	public function rest_delete_all_jobs( \WP_REST_Request $request ): \WP_REST_Response {
		global $wpdb;
		$table   = $this->get_table();
		$user_id = get_current_user_id();
		$module  = $request->get_param( 'module' );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT job_id, status FROM $table WHERE user_id = %d AND module = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$user_id,
				$module
			)
		);

		$cancelled = 0;
		$deleted   = 0;

		foreach ( $rows as $row ) {
			if ( 'processing' === $row->status ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->update(
					$table,
					array( 'status' => 'cancelled' ),
					array( 'job_id' => $row->job_id ),
					array( '%s' ),
					array( '%s' )
				);
				++$cancelled;
				continue;
			}

			if ( 'pending' === $row->status ) {
				as_unschedule_action( 'jwp_stk_process_job', array( 'job_id' => $row->job_id ), 'jwp-stk' );
			}

			// Let modules clean up their own data before the row is removed.
			do_action( 'jwp_stk_job_before_delete', $row->job_id, $module );

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->delete( $table, array( 'job_id' => $row->job_id ), array( '%s' ) );
			++$deleted;
		}

		return new \WP_REST_Response(
			array(
				'deleted'   => $deleted,
				'cancelled' => $cancelled,
			),
			200
		);
	}

	/**
	 * Delete jobs for a module that are older than a given number of days.
	 *
	 * Used by the scheduled cleanup task. Skips actively processing jobs.
	 *
	 * @param string $module Module slug.
	 * @param int    $days   Delete jobs created more than this many days ago.
	 * @return int Number of rows deleted.
	 */
	public function delete_jobs_older_than( string $module, int $days ): int {
		global $wpdb;
		$table = $this->get_table();
		$days  = max( 1, $days );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT job_id, status FROM $table
				WHERE module = %s
				  AND status != 'processing'
				  AND created_at < DATE_SUB( NOW(), INTERVAL %d DAY )",
				$module,
				$days
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		$count = 0;
		foreach ( $rows as $row ) {
			if ( 'pending' === $row->status ) {
				as_unschedule_action( 'jwp_stk_process_job', array( 'job_id' => $row->job_id ), 'jwp-stk' );
			}
			// Let modules clean up their own data before the row is removed.
			do_action( 'jwp_stk_job_before_delete', $row->job_id, $module );
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->delete( $table, array( 'job_id' => $row->job_id ), array( '%s' ) );
			++$count;
		}

		return $count;
	}

	/**
	 * Cancel a pending or processing job.
	 *
	 * @param \WP_REST_Request $request Request object.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function rest_cancel_job( \WP_REST_Request $request ) {
		global $wpdb;
		$job_id  = $request->get_param( 'id' );
		$table   = $this->get_table();
		$user_id = get_current_user_id();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$row = $wpdb->get_row(
			$wpdb->prepare( "SELECT * FROM $table WHERE job_id = %s AND user_id = %d", $job_id, $user_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		);

		if ( ! $row ) {
			return new \WP_Error(
				'jwp_stk_job_not_found',
				__( 'Job not found.', 'jetix-store-toolkit' ),
				array( 'status' => 404 )
			);
		}

		if ( 'processing' === $row->status ) {
			// Cannot delete a mid-flight job; mark cancelled so the handler aborts.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->update(
				$table,
				array( 'status' => 'cancelled' ),
				array( 'job_id' => $job_id ),
				array( '%s' ),
				array( '%s' )
			);
			return new \WP_REST_Response( array( 'cancelled' => true ), 200 );
		}

		// For all other statuses (pending, completed, failed, cancelled): delete the row.
		if ( 'pending' === $row->status ) {
			as_unschedule_action( 'jwp_stk_process_job', array( 'job_id' => $job_id ), 'jwp-stk' );
		}

		// Let modules clean up their own data before the row is removed.
		do_action( 'jwp_stk_job_before_delete', $job_id, $row->module );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->delete(
			$table,
			array( 'job_id' => $job_id ),
			array( '%s' )
		);

		return new \WP_REST_Response( array( 'deleted' => true ), 200 );
	}
}
