<?php
/**
 * WorkflowRunner — orchestrates play execution.
 *
 * v1.3 additions:
 *   - DeferredStateCheck: re-validates trigger before delayed plays fire.
 *   - Per-customer frequency limit: checks ap_play_runs before every execution.
 *   - Queue cancellation: is_still_valid() makes purchased-cart plays no-op.
 *   - Passes workflow_id into Context so actions (e.g. Mailer) can use it.
 *
 * v1.5 additions:
 *   - Multi-step sequences: when a play defines `sequence`, each step runs one
 *     action by index after its delay (chain is relative to the previous step).
 *
 * @package CartCue
 */

namespace CartCue;

defined( 'ABSPATH' ) || exit;

class WorkflowRunner {

	private Plugin $plugin;

	public function __construct( Plugin $plugin ) {
		$this->plugin = $plugin;
	}

	// -------------------------------------------------------------------------
	// Public API
	// -------------------------------------------------------------------------

	/**
	 * Immediate-or-delayed trigger entry point.
	 * Called once per workflow per trigger event.
	 */
	public function trigger( int $workflow_id, array $data ): void {
		$workflow = $this->get_workflow( $workflow_id );
		if ( ! $workflow ) {
			return;
		}
		$data = $this->normalize_trigger_data( (string) ( $workflow['trigger_slug'] ?? '' ), $data );

		if ( ! EnvironmentGuard::should_run_workflows( $data ) ) {
			return;
		}

		do_action( 'cartcue_workflow_triggered', $workflow_id, $workflow, $data );

		if ( ! $this->passes_initial_checks( $workflow, $data ) ) {
			return;
		}

		$timing = $workflow['timing'] ?? [ 'type' => 'immediate' ];
		$type   = $timing['type'] ?? 'immediate';

		if ( 'delay' === $type ) {
			$this->queue( $workflow_id, $data, $this->timing_to_seconds( $timing ) );
		} elseif ( 'schedule_time' === $type ) {
			$delay = $this->seconds_until_store_time( $timing );
			$this->queue( $workflow_id, $data, $delay > 0 ? $delay : 0 );
		} else {
			$this->execute( $workflow_id, $data );
		}
	}

	/** Fire all active plays registered to a trigger slug. */
	public function fire_trigger( string $trigger_slug, array $data ): void {
		foreach ( $this->get_active_workflows_for_trigger( $trigger_slug ) as $wf ) {
			$this->trigger( (int) $wf['id'], $data );
		}
	}

	/** Action Scheduler callback for initial delayed run. */
	public function run_queued( int $workflow_id, array $data ): void {
		$workflow = $this->get_workflow( $workflow_id );
		if ( ! $workflow ) {
			return;
		}

		if ( ! EnvironmentGuard::should_run_workflows( $data ) ) {
			return;
		}

		try {
			$this->execute( $workflow_id, $data, true );
		} catch ( NonRetryableWorkflowException $e ) {
			$this->plugin->logger->log(
				$workflow_id,
				$workflow['name'],
				$workflow['trigger_slug'] ?? '',
				$data,
				'error',
				sprintf( 'Queued run non-retryable: %s', $e->getMessage() )
			);
			do_action( 'cartcue_workflow_failed', $workflow_id, $workflow, $data, $e->getMessage() );
		} catch ( \Throwable $e ) {
			$this->plugin->logger->log(
				$workflow_id,
				$workflow['name'],
				$workflow['trigger_slug'] ?? '',
				$data,
				'error',
				sprintf( 'Queued run failed: %s', $e->getMessage() )
			);
			do_action( 'cartcue_workflow_failed', $workflow_id, $workflow, $data, $e->getMessage() );
		}
	}

	/**
	 * Action Scheduler callback for a single sequence step.
	 *
	 * @param int               $workflow_id Workflow ID.
	 * @param array<string,mixed> $data      Trigger payload (must remain serializable for AS).
	 * @param int               $step_index  Index into workflow `sequence` array.
	 */
	public function run_sequence_step( int $workflow_id, array $data, int $step_index ): void {
		$workflow = $this->get_workflow( $workflow_id );
		if ( ! $workflow || 'active' !== $workflow['status'] || ! $this->uses_sequence( $workflow ) ) {
			return;
		}

		if ( ! EnvironmentGuard::should_run_workflows( $data ) ) {
			return;
		}

		$this->execute( $workflow_id, $data, true, $step_index );
	}

	// -------------------------------------------------------------------------
	// Core execution (unified)
	// -------------------------------------------------------------------------

	/**
	 * @param array<string,mixed> $data
	 */
	private function execute( int $workflow_id, array $data, bool $is_queued = false, ?int $sequence_step = null ): void {
		$workflow = $this->get_workflow( $workflow_id );
		if ( ! $workflow || 'active' !== $workflow['status'] ) {
			return;
		}

		if ( ! $this->plugin->is_play_allowed_to_run( $workflow_id ) ) {
			$this->log_skipped( $workflow, $data, 'active_play_limit' );
			return;
		}

		$data['workflow_id'] = $workflow_id;
		do_action( 'cartcue_workflow_started', $workflow_id, $workflow, $data, $is_queued );

		$email = sanitize_email( $data['customer_email'] ?? '' );
		if ( $email && empty( $data['_is_test'] ) ) {
			JourneyRecorder::record(
				$email,
				'workflow_started',
				sprintf( 'Started: %s', (string) ( $workflow['name'] ?? '' ) ),
				$workflow_id
			);
		}

		if ( null !== $sequence_step ) {
			if ( ! $this->passes_sequence_step_guards( $workflow, $data ) ) {
				return;
			}
			$this->execute_sequence_step( $workflow, new Context( $data ), $sequence_step );
			return;
		}

		if ( ! $this->passes_all_guards( $workflow, $data, $is_queued ) ) {
			return;
		}

		if ( ! $this->reserve_frequency_slot( $workflow, $data ) ) {
			$this->log_skipped( $workflow, $data, 'frequency_limit' );
			return;
		}

		if ( $this->uses_sequence( $workflow ) ) {
			$this->schedule_sequence_step( $workflow_id, $data, 0 );
			return;
		}

		$ctx    = new Context( $data );
		$all_ok = true;

		foreach ( $workflow['actions'] ?? [] as $action_config ) {
			if ( ! $this->passes_action_condition( $action_config, $ctx->all() ) ) {
				$this->plugin->logger->log(
					$workflow_id,
					$workflow['name'],
					$workflow['trigger_slug'],
					$ctx->all(),
					'skipped',
					'Action skipped: condition not met'
				);
				continue;
			}
			if ( ! $this->execute_single_action( $workflow, $action_config, $ctx ) ) {
				$all_ok = false;
				if ( $this->is_critical_action( $action_config['type'] ?? '' ) ) {
					break;
				}
			}
		}

		if ( $all_ok ) {
			$this->finalize_successful_run( $workflow_id, $workflow, $ctx->all() );
			do_action( 'cartcue_workflow_completed', $workflow_id, $workflow, $ctx->all() );
		} else {
			do_action( 'cartcue_workflow_failed', $workflow_id, $workflow, $ctx->all(), 'one_or_more_actions_failed' );
		}
	}

	private function execute_sequence_step( array $workflow, Context $ctx, int $step_index ): void {
		$workflow_id = (int) $workflow['id'];
		$sequence    = $workflow['sequence'] ?? [];

		if ( ! isset( $sequence[ $step_index ] ) ) {
			return;
		}

		$step = $sequence[ $step_index ];

		if ( 'branch' === ( $step['step_type'] ?? 'action' ) ) {
			$this->execute_branch_step( $workflow, $ctx, $step_index, $step );
			return;
		}

		$action_index  = (int) ( $step['action_index'] ?? 0 );
		$action_config = $workflow['actions'][ $action_index ] ?? null;

		if ( ! $action_config ) {
			$this->log_error( $workflow, $ctx, sprintf( 'Sequence step %d: no action at index %d', $step_index, $action_index ) );
			return;
		}

		if ( ! $this->passes_action_condition( $action_config, $ctx->all() ) ) {
			$this->plugin->logger->log(
				$workflow_id,
				$workflow['name'],
				$workflow['trigger_slug'] ?? '',
				$ctx->all(),
				'skipped',
				sprintf( 'Sequence step %d skipped: action condition not met', $step_index )
			);
			$this->schedule_next_sequence_step( $workflow, $ctx, $step_index );
			return;
		}

		$slug = $action_config['type'] ?? '';
		if ( ! $this->execute_single_action( $workflow, $action_config, $ctx, $step_index ) ) {
			if ( $this->is_critical_action( $slug ) ) {
				do_action( 'cartcue_workflow_failed', $workflow_id, $workflow, $ctx->all(), 'critical_action_failed' );
				return;
			}
			do_action( 'cartcue_workflow_failed', $workflow_id, $workflow, $ctx->all(), 'sequence_action_failed' );
			return;
		}

		$this->schedule_next_sequence_step( $workflow, $ctx, $step_index );
	}

	/**
	 * @param array<string,mixed> $workflow
	 * @param array<string,mixed> $action_config
	 */
	private function execute_single_action( array $workflow, array $action_config, Context $ctx, ?int $sequence_step = null ): bool {
		$workflow_id = (int) $workflow['id'];
		$slug        = $action_config['type'] ?? '';
		$action      = $this->plugin->get_action( $slug );

		if ( ! $action ) {
			$this->log_error( $workflow, $ctx, sprintf( 'Action "%s" failed: action is not registered', $slug ) );
			do_action( 'cartcue_workflow_action_failed', $workflow_id, $workflow, $slug, $ctx->all(), 'unknown_action' );
			if ( null === $sequence_step ) {
				return false;
			}
			do_action( 'cartcue_workflow_failed', $workflow_id, $workflow, $ctx->all(), 'unknown_action' );
			return false;
		}

		if ( EnvironmentGuard::should_skip_action( $slug, $ctx->all() ) ) {
			$this->plugin->logger->log(
				$workflow_id,
				$workflow['name'],
				$workflow['trigger_slug'] ?? '',
				$ctx->all(),
				'skipped',
				sprintf( 'Action "%s" skipped: %s', $slug, EnvironmentGuard::get_block_reason() )
			);
			return true;
		}

		do_action( 'cartcue_workflow_action_started', $workflow_id, $workflow, $slug, $ctx->all() );

		try {
			$action->run( $action_config['settings'] ?? [], $ctx );
			do_action( 'cartcue_workflow_action_completed', $workflow_id, $workflow, $slug, $ctx->all() );
			if ( null !== $sequence_step ) {
				$this->plugin->logger->log(
					$workflow_id,
					$workflow['name'],
					$workflow['trigger_slug'] ?? '',
					$ctx->all(),
					'success',
					sprintf( 'Sequence step %d completed', $sequence_step )
				);
			}
			return true;
		} catch ( NonRetryableWorkflowException $e ) {
			$this->log_action_failure( $workflow, $ctx, $slug, $e->getMessage(), true, $sequence_step );
			return false;
		} catch ( \Throwable $e ) {
			$this->log_action_failure( $workflow, $ctx, $slug, $e->getMessage(), false, $sequence_step );
			return false;
		}
	}

	private function execute_branch_step( array $workflow, Context $ctx, int $step_index, array $step ): void {
		if ( ! $this->plugin->can_use_branching() ) {
			$this->schedule_next_sequence_step( $workflow, $ctx, $step_index );
			return;
		}

		$workflow_id = (int) $workflow['id'];
		$passes      = $this->evaluate_rules_list( $step['rules'] ?? [], $ctx->all(), $workflow );
		$next        = $passes
			? (int) ( $step['next_if_true'] ?? ( $step_index + 1 ) )
			: (int) ( $step['next_if_false'] ?? ( $step_index + 1 ) );

		$this->plugin->logger->log(
			$workflow_id,
			$workflow['name'],
			$workflow['trigger_slug'] ?? '',
			$ctx->all(),
			'success',
			sprintf( 'Branch step %d: %s → step %d', $step_index, $passes ? 'yes' : 'no', $next )
		);

		$email = sanitize_email( $ctx->get( 'customer_email', '' ) );
		if ( $email ) {
			JourneyRecorder::record(
				$email,
				'branch',
				sprintf( 'Branch step %d → %d (%s)', $step_index, $next, $passes ? 'yes' : 'no' ),
				$workflow_id
			);
		}

		if ( isset( $workflow['sequence'][ $next ] ) ) {
			$this->schedule_sequence_step( $workflow_id, $ctx->all(), $next );
			return;
		}

		$this->finalize_successful_run( $workflow_id, $workflow, $ctx->all() );
		do_action( 'cartcue_workflow_completed', $workflow_id, $workflow, $ctx->all() );
	}

	private function schedule_next_sequence_step( array $workflow, Context $ctx, int $current_index ): void {
		$workflow_id = (int) $workflow['id'];
		$step        = $workflow['sequence'][ $current_index ] ?? [];
		$next        = isset( $step['next_step'] ) ? (int) $step['next_step'] : ( $current_index + 1 );

		if ( isset( $workflow['sequence'][ $next ] ) ) {
			$this->schedule_sequence_step( $workflow_id, $ctx->all(), $next );
			return;
		}

		$this->finalize_successful_run( $workflow_id, $workflow, $ctx->all() );
		do_action( 'cartcue_workflow_completed', $workflow_id, $workflow, $ctx->all() );
	}

	// -------------------------------------------------------------------------
	// Guards & logging
	// -------------------------------------------------------------------------

	/**
	 * @param array<string,mixed> $workflow
	 * @param array<string,mixed> $data
	 */
	private function passes_initial_checks( array $workflow, array $data ): bool {
		if ( 'active' === ( $workflow['status'] ?? '' ) && ! $this->plugin->is_play_allowed_to_run( (int) $workflow['id'] ) ) {
			$this->log_skipped( $workflow, $data, 'active_play_limit' );
			return false;
		}
		if ( ! $this->passes_trigger_rate_limit( (int) $workflow['id'], (string) ( $workflow['trigger_slug'] ?? '' ), $data ) ) {
			$this->log_skipped( $workflow, $data, 'trigger_rate_limited' );
			return false;
		}
		return true;
	}

	/**
	 * @param array<string,mixed> $workflow
	 * @param array<string,mixed> $data
	 */
	private function passes_all_guards( array $workflow, array $data, bool $is_queued ): bool {
		if ( $is_queued && ! $this->passes_recheck( $workflow, $data ) ) {
			$this->log_skipped( $workflow, $data, 'deferred_recheck_failed' );
			return false;
		}
		if ( ! $this->passes_frequency_limit( $workflow, $data ) ) {
			$this->log_skipped( $workflow, $data, 'frequency_limit' );
			return false;
		}
		if ( ! $this->passes_global_email_cap( $workflow, $data ) ) {
			$this->log_skipped( $workflow, $data, 'global_email_cap' );
			return false;
		}
		if ( ! $this->check_rules( $workflow, $data ) ) {
			$this->log_skipped( $workflow, $data, 'rules_not_met' );
			return false;
		}
		return true;
	}

	/**
	 * @param array<string,mixed> $workflow
	 * @param array<string,mixed> $data
	 */
	private function passes_sequence_step_guards( array $workflow, array $data ): bool {
		if ( ! $this->passes_recheck( $workflow, $data ) ) {
			$this->log_skipped( $workflow, $data, 'sequence_recheck_failed' );
			return false;
		}
		if ( ! $this->passes_global_email_cap( $workflow, $data ) ) {
			$this->log_skipped( $workflow, $data, 'global_email_cap' );
			return false;
		}
		return true;
	}

	/**
	 * @param array<string,mixed> $workflow
	 * @param array<string,mixed> $data
	 */
	private function log_skipped( array $workflow, array $data, string $reason ): void {
		$messages = [
			'active_play_limit'        => 'Active play limit reached (free plan)',
			'trigger_rate_limited'     => 'Trigger rate limit reached',
			'deferred_recheck_failed'  => 'Deferred recheck: trigger condition no longer valid',
			'sequence_recheck_failed'  => 'Sequence step: deferred recheck failed',
			'frequency_limit'          => 'Per-customer frequency limit reached',
			'global_email_cap'         => 'Global email frequency cap reached',
			'rules_not_met'            => 'Conditions not met',
		];
		$message  = $messages[ $reason ] ?? ucfirst( str_replace( '_', ' ', $reason ) );
		$message  = sprintf( '[skip:%s] %s', $reason, $message );

		$this->plugin->logger->log(
			(int) $workflow['id'],
			$workflow['name'],
			$workflow['trigger_slug'] ?? '',
			$data,
			'skipped',
			$message
		);
		do_action( 'cartcue_workflow_skipped', (int) $workflow['id'], $workflow, $data, $reason );
	}

	private function log_error( array $workflow, Context $ctx, string $message ): void {
		$this->plugin->logger->log(
			(int) $workflow['id'],
			$workflow['name'],
			$workflow['trigger_slug'] ?? '',
			$ctx->all(),
			'error',
			$message
		);
	}

	private function log_action_failure( array $workflow, Context $ctx, string $slug, string $msg, bool $non_retryable, ?int $sequence_step = null ): void {
		$workflow_id = (int) $workflow['id'];
		do_action( 'cartcue_workflow_action_failed', $workflow_id, $workflow, $slug, $ctx->all(), $msg );

		if ( null !== $sequence_step ) {
			$label = $non_retryable ? 'non-retryable' : 'failed';
			$this->plugin->logger->log(
				$workflow_id,
				$workflow['name'],
				$workflow['trigger_slug'] ?? '',
				$ctx->all(),
				'error',
				sprintf( 'Sequence step %d (%s) %s: %s', $sequence_step, $slug, $label, $msg )
			);
			return;
		}

		$label = $non_retryable ? 'non-retryable' : 'failed';
		$this->plugin->logger->log(
			$workflow_id,
			$workflow['name'],
			$workflow['trigger_slug'] ?? '',
			$ctx->all(),
			'error',
			sprintf( 'Action "%s" %s: %s', $slug, $label, $msg )
		);
	}

	private function finalize_successful_run( int $workflow_id, array $workflow, array $log_data ): void {
		$msg = $this->uses_sequence( $workflow )
			? 'Multi-step sequence completed'
			: 'Play completed';
		$this->plugin->logger->log(
			$workflow_id,
			$workflow['name'],
			$workflow['trigger_slug'],
			$log_data,
			'success',
			$msg
		);

		$this->increment_run_count( $workflow_id );
		$this->record_play_run( $workflow_id, $log_data );
	}

	private function schedule_sequence_step( int $workflow_id, array $data, int $step_index ): void {
		if ( ! function_exists( 'as_schedule_single_action' ) ) {
			return;
		}

		$hops = (int) ( $data['_seq_hops'] ?? 0 );
		if ( $hops >= (int) apply_filters( 'cartcue_max_sequence_hops', 50 ) ) {
			$this->plugin->logger->log( $workflow_id, '', '', $data, 'error', 'Sequence hop limit reached — aborting to prevent a loop.' );
			return;
		}
		$data['_seq_hops'] = $hops + 1;

		$max_steps = $this->plugin->max_sequence_steps();
		if ( $max_steps > 0 && $step_index >= $max_steps ) {
			return;
		}
		$workflow = $this->get_workflow( $workflow_id );
		if ( ! $workflow ) {
			return;
		}
		$sequence = $workflow['sequence'] ?? [];
		if ( ! isset( $sequence[ $step_index ] ) ) {
			return;
		}
		$delay = $this->sequence_step_delay_seconds( $sequence[ $step_index ] );
		$delay += QuietHours::extra_delay_seconds( time() + max( 0, $delay ), $data );
		as_schedule_single_action(
			time() + max( 0, $delay ),
			'cartcue_sequence_step',
			[ $workflow_id, $data, $step_index ],
			'cartcue'
		);
	}

	/**
	 * @param array<string,mixed> $step One sequence entry from JSON.
	 */
	/**
	 * @param array<string,mixed> $workflow
	 */
	private function uses_sequence( array $workflow ): bool {
		return ! empty( $workflow['sequence'] );
	}

	private function sequence_step_delay_seconds( array $step ): int {
		return $this->delay_to_seconds(
			(int) ( $step['delay'] ?? 1 ),
			(string) ( $step['delay_unit'] ?? 'hours' )
		);
	}

	private function delay_to_seconds( int $value, string $unit ): int {
		switch ( $unit ) {
			case 'seconds':
				return max( 0, $value );
			case 'minutes':
				return $value * MINUTE_IN_SECONDS;
			case 'days':
				return $value * DAY_IN_SECONDS;
			case 'weeks':
				return $value * WEEK_IN_SECONDS;
			case 'hours':
			default:
				return $value * HOUR_IN_SECONDS;
		}
	}

	/**
	 * Optional re-verify before delayed execution (trigger + workflow setting).
	 *
	 * @param array<string,mixed> $workflow Decoded workflow.
	 * @param array<string,mixed> $data     Trigger payload.
	 */
	private function passes_recheck( array $workflow, array $data ): bool {
		$trigger_slug = $workflow['trigger_slug'] ?? '';
		$trigger      = $this->plugin->get_trigger( $trigger_slug );
		$recheck      = (bool) ( $workflow['trigger_settings']['recheck_before_run'] ?? false );

		if ( $trigger && $recheck && $trigger->supports_recheck() ) {
			return $trigger->is_still_valid( $data );
		}

		return true;
	}

	/**
	 * Atomically reserve a per-customer frequency slot.
	 *
	 * Records the run in {@see DB::play_runs()} only if BOTH limits still allow it,
	 * via a single conditional INSERT. This closes the check-then-act gap that the
	 * old cooldown branch had (SELECT in PHP, then an unconditional INSERT), where
	 * two concurrent workers could both read 0 and both send.
	 *
	 * @param array<string,mixed> $workflow
	 * @param array<string,mixed> $data
	 */
	private function reserve_frequency_slot( array $workflow, array $data ): bool {
		$max_runs = (int) ( $workflow['max_runs_per_customer'] ?? 0 );
		$cooldown = (int) ( $workflow['cooldown_days'] ?? 0 );
		if ( 0 === $max_runs && 0 === $cooldown ) {
			return true;
		}

		$email = sanitize_email( $data['customer_email'] ?? '' );
		if ( ! $email || ! empty( $data['_is_test'] ) ) {
			return true;
		}

		global $wpdb;
		$table       = DB::play_runs();
		$workflow_id = (int) ( $workflow['id'] ?? 0 );
		$now         = current_time( 'mysql' );

		// One atomic conditional insert. The row is written (rows_affected = 1) only
		// when the max-runs ceiling has not been reached AND no prior run falls inside
		// the cooldown window. Passing 0 for either limit disables that half of the
		// predicate, so this single statement covers all combinations.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
		$wpdb->query(
			$wpdb->prepare(
				"INSERT INTO %i (workflow_id, customer_email, fired_at)
				 SELECT %d, %s, %s FROM DUAL
				 WHERE ( %d = 0 OR ( SELECT COUNT(*) FROM %i WHERE workflow_id = %d AND customer_email = %s ) < %d )
				   AND ( %d = 0 OR NOT EXISTS (
				         SELECT 1 FROM %i
				         WHERE workflow_id = %d AND customer_email = %s
				           AND fired_at >= DATE_SUB( NOW(), INTERVAL %d DAY )
				   ) )",
				$table,
				$workflow_id,
				$email,
				$now,
				$max_runs,
				$table,
				$workflow_id,
				$email,
				$max_runs,
				$cooldown,
				$table,
				$workflow_id,
				$email,
				$cooldown
			)
		);

		return $wpdb->rows_affected > 0;
	}

	// -------------------------------------------------------------------------
	// Per-customer frequency limit (read-only pre-check)
	// -------------------------------------------------------------------------

	private function passes_frequency_limit( array $workflow, array $data ): bool {
		$max_runs = (int) ( $workflow['max_runs_per_customer'] ?? 0 );
		$cooldown = (int) ( $workflow['cooldown_days'] ?? 0 );
		if ( 0 === $max_runs && 0 === $cooldown ) {
			return true;
		}

		$email = sanitize_email( $data['customer_email'] ?? '' );
		if ( ! $email ) {
			return true;
		}

		global $wpdb;

		if ( $cooldown > 0 ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$recent = (int) $wpdb->get_var(
				$wpdb->prepare(
					'SELECT COUNT(*) FROM %i WHERE workflow_id = %d AND customer_email = %s
					  AND fired_at >= DATE_SUB(NOW(), INTERVAL %d DAY)',
					DB::play_runs(),
					$workflow['id'],
					$email,
					$cooldown
				)
			);
			if ( $recent > 0 ) {
				return false;
			}
		}

		if ( $max_runs > 0 ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$total = (int) $wpdb->get_var(
				$wpdb->prepare(
					'SELECT COUNT(*) FROM %i WHERE workflow_id = %d AND customer_email = %s',
					DB::play_runs(),
					$workflow['id'],
					$email
				)
			);
			if ( $total >= $max_runs ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Actions whose failure should stop subsequent steps in the same run.
	 *
	 * create_coupon is critical because later steps (e.g. send_email) may reference
	 * the generated code — a failed coupon means downstream actions would send broken
	 * content. Email failures are non-critical: skipping a follow-up is safer than
	 * aborting the whole run when the primary message already went out.
	 */
	private function is_critical_action( string $slug ): bool {
		return in_array( $slug, [ 'create_coupon' ], true );
	}

	/**
	 * @param array<string, mixed> $workflow
	 * @param array<string, mixed> $data
	 */
	private function passes_global_email_cap( array $workflow, array $data ): bool {
		if ( ! GlobalEmailCap::is_enabled() ) {
			return true;
		}
		if ( ! self::workflow_sends_email( $workflow ) ) {
			return true;
		}
		$email = sanitize_email( $data['customer_email'] ?? '' );
		if ( ! $email || ! empty( $data['_is_test'] ) ) {
			return true;
		}
		if ( ! empty( $workflow['bypass_global_cap'] ) ) {
			return true;
		}
		$trigger = (string) ( $workflow['trigger_slug'] ?? '' );
		if ( in_array( $trigger, [ 'order_paid', 'payment_failed', 'customer_created' ], true ) ) {
			return true;
		}
		return GlobalEmailCap::can_send_to( $email );
	}

	/**
	 * @param array<string, mixed> $workflow
	 */
	public static function workflow_sends_email( array $workflow ): bool {
		$actions  = $workflow['actions'] ?? [];
		$sequence = $workflow['sequence'] ?? [];

		if ( is_string( $actions ) ) {
			$decoded  = json_decode( $actions, true );
			$actions  = is_array( $decoded ) ? $decoded : [];
		}
		if ( is_string( $sequence ) ) {
			$decoded  = json_decode( $sequence, true );
			$sequence = is_array( $decoded ) ? $decoded : [];
		}

		foreach ( $actions as $action ) {
			if ( 'send_email' === ( $action['type'] ?? '' ) ) {
				return true;
			}
		}
		foreach ( $sequence as $step ) {
			if ( 'branch' === ( $step['step_type'] ?? 'action' ) ) {
				continue;
			}
			$idx = (int) ( $step['action_index'] ?? -1 );
			$act = $actions[ $idx ] ?? null;
			if ( $act && 'send_email' === ( $act['type'] ?? '' ) ) {
				return true;
			}
		}
		return false;
	}

	private function record_play_run( int $workflow_id, array $data ): void {
		global $wpdb;
		$email = sanitize_email( $data['customer_email'] ?? '' );
		if ( ! $email || ! empty( $data['_is_test'] ) ) {
			return;
		}

		$workflow = $this->get_workflow( $workflow_id );
		$max_runs = (int) ( $workflow['max_runs_per_customer'] ?? 0 );
		$cooldown = (int) ( $workflow['cooldown_days'] ?? 0 );

		// When either frequency limit is set, reserve_frequency_slot() has already
		// inserted the play_runs row atomically at the start of the run. Inserting
		// again here would duplicate it (and inflate cooldown/window counts), so bail.
		// Only the "no limits" case falls through to record a reporting-only row.
		if ( $max_runs > 0 || $cooldown > 0 ) {
			return;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$wpdb->insert( DB::play_runs(), [
			'workflow_id'    => $workflow_id,
			'customer_email' => $email,
			'fired_at'       => current_time( 'mysql' ),
		], [ '%d', '%s', '%s' ] );
	}

	public function prune_play_runs(): void {
		global $wpdb;

		$days = (int) apply_filters( 'cartcue_play_runs_retention_days', 90 );
		$days = max( 1, $days );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$wpdb->query(
			$wpdb->prepare(
				'DELETE FROM %i WHERE fired_at < DATE_SUB(NOW(), INTERVAL %d DAY)',
				DB::play_runs(),
				$days
			)
		);
	}

	// -------------------------------------------------------------------------
	// Rule evaluation
	// -------------------------------------------------------------------------

	private function check_rules( array $workflow, array $data ): bool {
		return $this->evaluate_rules_list( $workflow['rules'] ?? [], $data, $workflow );
	}

	/**
	 * @param array<int, array<string, mixed>> $rules
	 * @param array<string, mixed>             $data
	 * @param array<string, mixed>             $workflow
	 */
	private function evaluate_rules_list( array $rules, array $data, array $workflow ): bool {
		foreach ( $rules as $rule_config ) {
			$slug = $rule_config['slug'] ?? '';

			$rule_result = apply_filters( 'cartcue_condition_evaluate_rule', null, $rule_config, $data, $workflow, $this->plugin );
			if ( is_bool( $rule_result ) ) {
				if ( ! $rule_result ) {
					return false;
				}
				continue;
			}

			$rule = $this->plugin->get_rule( $slug );
			if ( ! $rule ) {
				continue;
			}

			$passes = $rule->evaluate( $rule_config, $data );
			$passes = (bool) apply_filters( 'cartcue_condition_evaluated', $passes, $rule_config, $data, $workflow, $this->plugin );
			if ( ! $passes ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * @param array<string,mixed> $action_config
	 * @param array<string,mixed> $data
	 */
	private function passes_action_condition( array $action_config, array $data ): bool {
		$condition = $action_config['condition'] ?? null;
		if ( ! is_array( $condition ) || empty( $condition['enabled'] ) ) {
			return true;
		}

		$field = sanitize_key( (string) ( $condition['field'] ?? '' ) );
		if ( '' === $field ) {
			return true;
		}
		$operator = sanitize_key( (string) ( $condition['operator'] ?? 'equals' ) );
		$expected = (string) ( $condition['value'] ?? '' );
		$actual   = $this->resolve_action_condition_field( $field, $data );
		return $this->compare_action_condition_values( $actual, $operator, $expected );
	}

	/**
	 * @param array<string,mixed> $data
	 * @return string|float
	 */
	private function resolve_action_condition_field( string $field, array $data ) {
		switch ( $field ) {
			case 'order_total':
				if ( isset( $data['order_total'] ) && is_numeric( $data['order_total'] ) ) {
					return (float) $data['order_total'];
				}
				$order_id = isset( $data['order_id'] ) ? absint( $data['order_id'] ) : 0;
				if ( $order_id > 0 ) {
					$order = wc_get_order( $order_id );
					if ( $order ) {
						return (float) $order->get_total();
					}
				}
				return 0.0;
			case 'order_currency':
				if ( ! empty( $data['order_currency'] ) ) {
					return (string) $data['order_currency'];
				}
				$order_id = isset( $data['order_id'] ) ? absint( $data['order_id'] ) : 0;
				if ( $order_id > 0 ) {
					$order = wc_get_order( $order_id );
					if ( $order ) {
						return (string) $order->get_currency();
					}
				}
				return '';
			case 'customer_total_spent':
				$customer_id = isset( $data['customer_id'] ) ? absint( $data['customer_id'] ) : 0;
				if ( $customer_id > 0 ) {
					return (float) wc_get_customer_total_spent( $customer_id );
				}
				return 0.0;
			case 'customer_email':
			default:
				return strtolower( sanitize_email( (string) ( $data['customer_email'] ?? '' ) ) );
		}
	}

	/**
	 * @param string|float $actual
	 */
	private function compare_action_condition_values( $actual, string $operator, string $expected ): bool {
		$actual_is_numeric   = is_numeric( $actual );
		$expected_is_numeric = is_numeric( $expected );
		if ( $actual_is_numeric && $expected_is_numeric ) {
			$left  = (float) $actual;
			$right = (float) $expected;
			switch ( $operator ) {
				case 'gt':
					return $left > $right;
				case 'gte':
					return $left >= $right;
				case 'lt':
					return $left < $right;
				case 'lte':
					return $left <= $right;
				case 'not_equals':
					return $left !== $right;
				case 'equals':
				default:
					return $left === $right;
			}
		}

		$left  = strtolower( trim( (string) $actual ) );
		$right = strtolower( trim( $expected ) );
		switch ( $operator ) {
			case 'contains':
				return '' !== $right && false !== strpos( $left, $right );
			case 'not_equals':
				return $left !== $right;
			case 'equals':
			default:
				return $left === $right;
		}
	}

	// -------------------------------------------------------------------------
	// Queue
	// -------------------------------------------------------------------------

	private function queue( int $workflow_id, array $data, int $delay_seconds ): void {
		if ( ! EnvironmentGuard::should_run_workflows( $data ) ) {
			return;
		}

		$delay_seconds = max( 0, $delay_seconds );
		$execute_at      = time() + $delay_seconds;
		$delay_seconds  += QuietHours::extra_delay_seconds( $execute_at, $data );

		do_action( 'cartcue_workflow_queued', $workflow_id, $data, $delay_seconds );
		if ( function_exists( 'as_schedule_single_action' ) ) {
			as_schedule_single_action(
				time() + max( 0, $delay_seconds ),
				'cartcue_run_workflow',
				[ $workflow_id, $data ],
				'cartcue'
			);
		} else {
			$this->execute( $workflow_id, $data );
		}
	}

	private function passes_trigger_rate_limit( int $workflow_id, string $trigger_slug, array $data ): bool {
		if ( ! empty( $data['_is_test'] ) ) {
			return true;
		}

		$window = (int) apply_filters( 'cartcue_trigger_rate_limit_window', 30, $workflow_id, $trigger_slug, $data );
		if ( $window <= 0 ) {
			return true;
		}

		$customer_email = sanitize_email( $data['customer_email'] ?? '' );
		$customer_id    = isset( $data['customer_id'] ) ? absint( $data['customer_id'] ) : 0;
		$cart_hash      = isset( $data['cart_hash'] ) ? sanitize_text_field( (string) $data['cart_hash'] ) : '';

		$identity = '';
		if ( $customer_email ) {
			$identity = strtolower( $customer_email );
		} elseif ( $customer_id > 0 ) {
			$identity = 'user:' . $customer_id;
		} elseif ( $cart_hash ) {
			$identity = 'cart:' . $cart_hash;
		}

		if ( '' === $identity ) {
			return true;
		}

		$key = 'cartcue_trigger_rl_' . md5( $workflow_id . '|' . $trigger_slug . '|' . $identity );
		return RateLimiter::claim_once( $key, $window );
	}

	/**
	 * @param array<string,mixed> $data
	 * @return array<string,mixed>
	 */
	private function normalize_trigger_data( string $trigger_slug, array $data ): array {
		$normalized                   = $data;
		$normalized['trigger']        = $trigger_slug ?: (string) ( $normalized['trigger'] ?? '' );
		$normalized['triggered_at']   = (string) ( $normalized['triggered_at'] ?? current_time( 'mysql' ) );
		$normalized['customer_email'] = sanitize_email( $normalized['customer_email'] ?? '' );
		$normalized['customer_id']    = isset( $normalized['customer_id'] ) ? absint( $normalized['customer_id'] ) : 0;
		$normalized['order_id']       = isset( $normalized['order_id'] ) ? absint( $normalized['order_id'] ) : 0;
		$normalized['cart_hash']      = isset( $normalized['cart_hash'] ) ? sanitize_text_field( (string) $normalized['cart_hash'] ) : '';

		return apply_filters( 'cartcue_trigger_payload', $normalized, $normalized['trigger'] );
	}

	// -------------------------------------------------------------------------
	// Data access
	// -------------------------------------------------------------------------

	public function get_workflow( int $id ): ?array {
		$cache_key = 'cartcue_wf_' . $id;
		$cached    = wp_cache_get( $cache_key, 'cartcue' );
		if ( false !== $cached ) {
			return $cached ?: null;
		}

		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$row = $wpdb->get_row( $wpdb->prepare(
			'SELECT * FROM %i WHERE id = %d',
			DB::workflows(),
			$id
		), ARRAY_A );

		$result = $row ? $this->decode( $row ) : null;
		wp_cache_set( $cache_key, $result ?? 0, 'cartcue', 300 );
		return $result;
	}

	public function bust_workflow_cache( int $id ): void {
		wp_cache_delete( 'cartcue_wf_' . $id, 'cartcue' );
	}

	public function get_active_workflows_for_trigger( string $slug ): array {
		$key    = 'cartcue_active_wf_' . sanitize_key( $slug );
		$cached = wp_cache_get( $key, 'cartcue' );
		if ( false !== $cached ) {
			return is_array( $cached ) ? $cached : [];
		}

		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT * FROM %i WHERE trigger_slug = %s AND status = 'active' ORDER BY id ASC",
			DB::workflows(),
			$slug
		), ARRAY_A );
		$result = array_map( [ $this, 'decode' ], $rows ?: [] );
		wp_cache_set( $key, $result, 'cartcue', 300 );
		return $result;
	}

	public function bust_active_workflows_cache( string $slug ): void {
		if ( '' === $slug ) {
			return;
		}
		wp_cache_delete( 'cartcue_active_wf_' . sanitize_key( $slug ), 'cartcue' );
	}

	private function decode( array $row ): array {
		$row['trigger_settings']      = json_decode( $row['trigger_settings']      ?? '{}', true ) ?: [];
		$row['rules']                 = json_decode( $row['rules']                 ?? '[]', true ) ?: [];
		$row['actions']               = json_decode( $row['actions']               ?? '[]', true ) ?: [];
		$row['sequence']              = json_decode( $row['sequence']              ?? '[]', true ) ?: [];
		$row['timing']                = json_decode( $row['timing']                ?? '{"type":"immediate"}', true ) ?: [ 'type' => 'immediate' ];
		$row['max_runs_per_customer'] = (int) ( $row['max_runs_per_customer'] ?? 0 );
		$row['cooldown_days']         = (int) ( $row['cooldown_days']         ?? 0 );
		return $row;
	}

	private function increment_run_count( int $id ): void {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$wpdb->query( $wpdb->prepare(
			'UPDATE %i SET run_count = run_count + 1, updated_at = %s WHERE id = %d',
			DB::workflows(),
			current_time( 'mysql' ),
			$id
		) );
		$this->bust_workflow_cache( $id );
	}

	private function timing_to_seconds( array $timing ): int {
		return $this->delay_to_seconds(
			(int) ( $timing['delay_value'] ?? 1 ),
			(string) ( $timing['delay_unit'] ?? 'hours' )
		);
	}

	/**
	 * Seconds until the next occurrence of schedule_hour:schedule_minute in the site timezone.
	 */
	private function seconds_until_store_time( array $timing ): int {
		$hour   = min( 23, max( 0, (int) ( $timing['schedule_hour']   ?? 9 ) ) );
		$minute = min( 59, max( 0, (int) ( $timing['schedule_minute'] ?? 0 ) ) );
		// allowed_days: array of ISO day numbers 1=Mon … 7=Sun (empty = all days).
		$days   = array_map( 'intval', (array) ( $timing['allowed_days'] ?? [] ) );

		$tz  = wp_timezone();
		$now = new \DateTimeImmutable( 'now', $tz );

		// Search up to 8 days ahead for a matching day.
		for ( $offset = 0; $offset <= 8; $offset++ ) {
			$candidate = $now->modify( "+{$offset} days" )->setTime( $hour, $minute, 0 );
			if ( $candidate <= $now ) {
				continue; // Must be in the future.
			}
			if ( ! empty( $days ) ) {
				$iso_day = (int) $candidate->format( 'N' ); // 1=Mon … 7=Sun
				if ( ! in_array( $iso_day, $days, true ) ) {
					continue; // Not an allowed day.
				}
			}
			return max( 0, $candidate->getTimestamp() - $now->getTimestamp() );
		}

		// Fallback: one hour from now.
		return HOUR_IN_SECONDS;
	}
}
