<?php
/**
 * Action Queue Processor Class
 *
 * Handles Action Scheduler callbacks for per-action workflow execution.
 * Each action in a workflow is processed as its own job.
 *
 * @package Sequensy
 * @since 2.0.0
 */

declare(strict_types=1);

namespace Sequensy\Engine;

use Exception;
use Sequensy\Exceptions\ActionException;
use Sequensy\Storage\ExecutionStorage;
use Sequensy\Storage\WorkflowStorage;
use Sequensy\Storage\VersionStorage;
use Sequensy\Storage\ActivityLogger;

// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
	die;
}

/**
 * Class ActionQueueProcessor
 *
 * Processes individual action jobs from Action Scheduler.
 * Manages the execution chain: execute action -> update state -> enqueue next.
 */
class ActionQueueProcessor {

	/**
	 * Execution storage instance.
	 *
	 * @var ExecutionStorage
	 */
	private ExecutionStorage $execution_storage;

	/**
	 * Workflow storage instance.
	 *
	 * @var WorkflowStorage
	 */
	private WorkflowStorage $workflow_storage;

	/**
	 * Version storage instance.
	 *
	 * @var VersionStorage
	 */
	private VersionStorage $version_storage;

	/**
	 * Workflow executor instance.
	 *
	 * @var WorkflowExecutor
	 */
	private WorkflowExecutor $executor;

	/**
	 * Activity logger instance.
	 *
	 * @var ActivityLogger
	 */
	private ActivityLogger $logger;

	/**
	 * Execution guard instance for loop prevention.
	 *
	 * @var ExecutionGuard
	 */
	private ExecutionGuard $execution_guard;

	/**
	 * Holds the result when resolve_execution_state() returns null.
	 *
	 * Set by resolve_execution_state() so the caller can return the
	 * appropriate early-exit response without duplicating array shapes.
	 *
	 * @var array<string, mixed>
	 */
	private array $skipped_result = array();

	/**
	 * Constructor.
	 *
	 * @param ExecutionStorage|null $execution_storage Optional execution storage instance.
	 * @param WorkflowStorage|null  $workflow_storage  Optional workflow storage instance.
	 * @param VersionStorage|null   $version_storage   Optional version storage instance.
	 * @param WorkflowExecutor|null $executor          Optional workflow executor instance.
	 * @param ActivityLogger|null   $logger            Optional activity logger instance.
	 * @param ExecutionGuard|null   $execution_guard   Optional execution guard instance.
	 */
	public function __construct(
		?ExecutionStorage $execution_storage = null,
		?WorkflowStorage $workflow_storage = null,
		?VersionStorage $version_storage = null,
		?WorkflowExecutor $executor = null,
		?ActivityLogger $logger = null,
		?ExecutionGuard $execution_guard = null
	) {
		$this->execution_storage = $execution_storage ?? new ExecutionStorage();
		$this->workflow_storage  = $workflow_storage ?? new WorkflowStorage();
		$this->version_storage   = $version_storage ?? new VersionStorage();
		$this->executor          = $executor ?? new WorkflowExecutor();
		$this->logger            = $logger ?? new ActivityLogger();
		$this->execution_guard   = $execution_guard ?? new ExecutionGuard();
	}

	/**
	 * Process a single action job.
	 *
	 * This method is called by Action Scheduler when a job is ready to run.
	 * It executes the action, updates execution state, and enqueues the next action.
	 *
	 * @param int    $execution_id Execution ID.
	 * @param string $action_id    ID of action to execute.
	 * @return array<string, mixed> Execution result.
	 *
	 * @throws \Sequensy\Exceptions\ActionException When the action fails with a retryable error,
	 *                                                  triggering Action Scheduler retry logic.
	 */
	public function process( int $execution_id, string $action_id ): array {
		// Load and validate execution state, version, and config.
		$state = $this->resolve_execution_state( $execution_id, $action_id );

		if ( null === $state ) {
			// Execution should be skipped — early return already determined.
			return $this->skipped_result;
		}

		$execution = $state['execution'];
		$config    = $state['config'];
		$action    = $config->get_action( $action_id );

		if ( ! $action ) {
			return $this->fail_with_error( $execution, 'Action not found', $action_id );
		}

		// Execute the action with loop prevention guard.
		$result = $this->dispatch_action( $execution, $action, $execution->context, $execution_id, $action_id );

		// If action failed, classify as retryable or fatal.
		if ( ! isset( $result['success'] ) || ! $result['success'] ) {
			return $this->handle_action_failure( $execution, $result, $action, $action_id );
		}

		// Update state and enqueue next action if any.
		return $this->advance_execution( $execution, $result, $action, $action_id, $config );
	}

	/**
	 * Load and validate the execution state for processing.
	 *
	 * Checks that the execution exists, is in a runnable state, and that the
	 * associated version and config are valid. Sets $this->skipped_result when
	 * returning null so the caller can return the appropriate response.
	 *
	 * @param int    $execution_id Execution ID.
	 * @param string $action_id    Action ID (for logging).
	 * @return array{execution: object, config: \Sequensy\ValueObjects\WorkflowConfig}|null
	 *         Null if execution should be skipped.
	 */
	private function resolve_execution_state( int $execution_id, string $action_id ): ?array {
		$execution = $this->execution_storage->get_execution( $execution_id );

		// Validate execution exists.
		if ( ! $execution ) {
			$this->logger->warning(
				0,
				'Execution not found, skipping action',
				array(
					'execution_id' => $execution_id,
					'action_id'    => $action_id,
				)
			);

			$this->skipped_result = array(
				'success' => true,
				'message' => 'Execution not found',
			);

			return null;
		}

		// Check execution status — only process if running or pending.
		if ( ! in_array( $execution->status, array( 'pending', 'running' ), true ) ) {
			$this->logger->info(
				$execution->workflow_id,
				'Action execution skipped - execution not in runnable state',
				array(
					'execution_id' => $execution_id,
					'action_id'    => $action_id,
					'status'       => $execution->status,
				),
				null,
				$execution_id
			);

			$this->skipped_result = array(
				'success' => true,
				'message' => 'Execution not in runnable state',
			);

			return null;
		}

		// If this is the first action, mark execution as running.
		if ( 'pending' === $execution->status ) {
			$this->execution_storage->start_execution( $execution_id );
		}

		// Load config from the version that was captured at execution start.
		// This ensures mid-execution edits don't affect running workflows.
		$version = $this->version_storage->get_version( $execution->version_id );

		if ( ! $version ) {
			$this->logger->error(
				$execution->workflow_id,
				'Version not found for execution',
				array(
					'execution_id'   => $execution_id,
					'version_id'     => $execution->version_id,
					'version_number' => $execution->version_number,
				),
				null,
				$execution_id
			);

			$this->execution_storage->fail_execution( $execution_id, 'Version not found' );

			$this->skipped_result = array(
				'success' => false,
				'error'   => 'Version not found',
			);

			return null;
		}

		// Get config from the version (parsed_config is a WorkflowConfig object).
		$config = $version->parsed_config;

		if ( ! $config ) {
			$this->logger->error(
				$execution->workflow_id,
				'Invalid config in version',
				array(
					'execution_id'   => $execution_id,
					'version_id'     => $execution->version_id,
					'version_number' => $execution->version_number,
				),
				null,
				$execution_id
			);

			$this->execution_storage->fail_execution( $execution_id, 'Invalid version config' );

			$this->skipped_result = array(
				'success' => false,
				'error'   => 'Invalid version config',
			);

			return null;
		}

		return array(
			'execution' => $execution,
			'config'    => $config,
		);
	}

	// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
	/**
	 * Execute an action via WorkflowExecutor with loop prevention guard.
	 *
	 * The guard blocks any triggers that fire during action execution to
	 * prevent infinite loops.
	 *
	 * @param object                              $execution    Execution record.
	 * @param \Sequensy\ValueObjects\ActionConfig $action       Action config to execute.
	 * @param array                               $context      Execution context.
	 * @param int                                 $execution_id Execution ID.
	 * @param string                              $action_id    Action ID.
	 * @return array The action result data.
	 */
	private function dispatch_action(
		object $execution,
		$action,
		array $context,
		int $execution_id,
		string $action_id
	): array {
		// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
		$this->execution_guard->begin_action_execution();

		try {
			$result = $this->executor->execute_single_action(
				$execution->workflow_id,
				$action,
				$context,
				$execution_id,
				null
			);
		} finally {
			$this->execution_guard->end_action_execution();
		}

		return $result;
	}

	/**
	 * Handle a failed action execution — classify as retryable or fatal.
	 *
	 * Retryable errors throw an ActionException to trigger Action Scheduler retry.
	 * Fatal errors mark the execution as failed and return immediately.
	 *
	 * @param object                              $execution Execution record.
	 * @param array                               $result    Action result data.
	 * @param \Sequensy\ValueObjects\ActionConfig $action    Action config.
	 * @param string                              $action_id Action ID.
	 * @return array<string, mixed> Result array (only returned for fatal errors).
	 *
	 * @throws \Sequensy\Exceptions\ActionException For retryable errors.
	 */
	private function handle_action_failure( object $execution, array $result, $action, string $action_id ): array {
		$execution_id  = $execution->id;
		$error_message = $result['error'] ?? 'Unknown error';
		$is_retryable  = $result['retryable'] ?? false;
		$action_type   = $action->get_type();

		if ( $is_retryable ) {
			// Retryable error — throw exception to trigger Action Scheduler retry.
			$this->logger->error(
				$execution->workflow_id,
				'Action failed (retryable), will retry',
				array(
					'execution_id' => $execution_id,
					'action_id'    => $action_id,
					'action_type'  => $action_type,
					'error'        => $error_message,
					'retryable'    => true,
				),
				null,
				$execution_id
			);

			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception constructor.
			throw ActionException::retryable(
				$error_message,
				$action_type,
				array(
					'execution_id' => $execution_id,
					'action_id'    => $action_id,
					'workflow_id'  => $execution->workflow_id,
				)
			);
			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}

		// Fatal error — fail execution immediately, no retry.
		$this->logger->error(
			$execution->workflow_id,
			'Action failed (fatal), marking execution as failed',
			array(
				'execution_id' => $execution_id,
				'action_id'    => $action_id,
				'action_type'  => $action_type,
				'error'        => $error_message,
				'retryable'    => false,
			),
			null,
			$execution_id
		);

		$this->execution_storage->fail_execution( $execution_id, $error_message );

		return array(
			'success' => false,
			'error'   => $error_message,
			'fatal'   => true,
		);
	}

	/**
	 * Update execution state and enqueue the next action if any.
	 *
	 * Merges the action output into the execution context, then either completes
	 * the execution or enqueues the next action via graph routing.
	 *
	 * @param object                                $execution Execution record.
	 * @param array                                 $result    Successful action result data.
	 * @param \Sequensy\ValueObjects\ActionConfig   $action    Action config.
	 * @param string                                $action_id Action ID.
	 * @param \Sequensy\ValueObjects\WorkflowConfig $config    Workflow config for graph routing.
	 * @return array<string, mixed> Result array.
	 */
	private function advance_execution(
		object $execution,
		array $result,
		$action,
		string $action_id,
		\Sequensy\ValueObjects\WorkflowConfig $config
	): array {
		$execution_id = $execution->id;

		// Update execution context with action output.
		$context   = $execution->context;
		$namespace = $this->executor->get_action_namespace( $action, null );

		if ( isset( $result['data'] ) ) {
			$context[ $namespace ] = $result['data'];
		}

		// Determine next action via graph routing.
		$matched_route  = $result['data']['matched_route'] ?? null;
		$next_action_id = $config->get_next_action_id( $action_id, $matched_route );

		if ( null === $next_action_id ) {
			// Terminal action — workflow complete.
			$this->execution_storage->update_execution_progress( $execution_id, '', $context );
			$this->execution_storage->complete_execution( $execution_id );

			$this->logger->info(
				$execution->workflow_id,
				'Workflow execution completed',
				array(
					'execution_id'  => $execution_id,
					'last_action'   => $action_id,
					'matched_route' => $matched_route,
					'total_actions' => $execution->total_actions,
				),
				null,
				$execution_id
			);

			return array(
				'success'   => true,
				'completed' => true,
			);
		}

		// Update progress and enqueue next action.
		$this->execution_storage->update_execution_progress( $execution_id, $next_action_id, $context );

		$job_id = $this->enqueue_next_action( $execution_id, $next_action_id );

		$this->logger->info(
			$execution->workflow_id,
			'Next action enqueued',
			array(
				'execution_id'  => $execution_id,
				'next_action'   => $next_action_id,
				'matched_route' => $matched_route,
				'job_id'        => $job_id,
			),
			null,
			$execution_id
		);

		return array(
			'success'       => true,
			'next_action'   => $next_action_id,
			'matched_route' => $matched_route,
			'job_id'        => $job_id,
		);
	}

	/**
	 * Fail the execution with an error and return the failure result.
	 *
	 * @param object $execution Execution record.
	 * @param string $error     Error message.
	 * @param string $action_id Action ID (for logging).
	 * @return array<string, mixed> Failure result array.
	 */
	private function fail_with_error( object $execution, string $error, string $action_id ): array {
		$this->logger->error(
			$execution->workflow_id,
			'Action not found in workflow config',
			array(
				'execution_id' => $execution->id,
				'action_id'    => $action_id,
			),
			null,
			$execution->id
		);

		$this->execution_storage->fail_execution( $execution->id, $error );

		return array(
			'success' => false,
			'error'   => $error,
		);
	}

	/**
	 * Enqueue the next action in the execution chain.
	 *
	 * @param int    $execution_id Execution ID.
	 * @param string $action_id    Action ID to enqueue.
	 * @return int|false Job ID or false on failure.
	 */
	private function enqueue_next_action( int $execution_id, string $action_id ): int|false {
		return as_enqueue_async_action(
			'sequensy_execute_action',
			array(
				'execution_id' => $execution_id,
				'action_id'    => $action_id,
			),
			'sequensy-workflows'
		);
	}

	/**
	 * Register Action Scheduler hooks.
	 *
	 * Called during plugin initialization to set up the action callback
	 * and failure detection hook.
	 */
	public static function register_hooks(): void {
		// Register action execution callback
		add_action(
			'sequensy_execute_action',
			array( self::class, 'handle_action_execution' ),
			10,
			2
		);

		// Register failure detection to mark execution as failed when AS gives up
		add_action(
			'action_scheduler_failed_execution',
			array( self::class, 'handle_failed_execution' ),
			10,
			3
		);

		// Configure retry delay (5 minutes = 300 seconds) for our actions only
		add_filter(
			'action_scheduler_failure_period',
			array( self::class, 'set_retry_delay' ),
			10,
			1
		);
	}

	/**
	 * Handle action execution callback from Action Scheduler.
	 *
	 * Static method registered as Action Scheduler callback.
	 *
	 * @param int    $execution_id Execution ID.
	 * @param string $action_id    Action ID.
	 * @return array<string, mixed> Execution result.
	 */
	public static function handle_action_execution( int $execution_id, string $action_id ): array {
		$processor = new self();
		return $processor->process( $execution_id, $action_id );
	}

	// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
	/**
	 * Handle failed execution when Action Scheduler gives up.
	 *
	 * Called when Action Scheduler exhausts retries for an action.
	 * Marks the execution as failed.
	 *
	 * @param int       $as_action_id Action Scheduler action ID.
	 * @param Exception $exception    The exception that caused the failure.
	 * @param mixed     $_context     Action Scheduler context (unused but required by hook signature).
	 */
	public static function handle_failed_execution(
		int $as_action_id,
		Exception $exception,
		mixed $_context
	): void {
		// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
		// Get the action details from Action Scheduler
		if ( ! class_exists( 'ActionScheduler' ) ) {
			return;
		}

		$action = \ActionScheduler::store()->fetch_action( $as_action_id );

		if ( ! $action ) {
			return;
		}

		// Check if this is our action
		if ( 'sequensy_execute_action' !== $action->get_hook() ) {
			return;
		}

		// Get execution ID from action args
		$args = $action->get_args();

		if ( ! isset( $args['execution_id'] ) ) {
			return;
		}

		$execution_id = $args['execution_id'];
		$action_id    = $args['action_id'] ?? '';

		// Mark execution as failed
		$execution_storage = new ExecutionStorage();
		$execution         = $execution_storage->get_execution( $execution_id );

		if ( ! $execution ) {
			return;
		}

		$error_message = sprintf(
			'Action "%s" failed after exhausting retries: %s',
			$action_id,
			$exception->getMessage()
		);

		$execution_storage->fail_execution( $execution_id, $error_message );

		// Log the failure
		$logger = new ActivityLogger();
		$logger->critical(
			$execution->workflow_id,
			'Workflow execution failed - action exhausted retries',
			array(
				'execution_id' => $execution_id,
				'action_id'    => $action_id,
				'error'        => $exception->getMessage(),
			),
			null,
			$execution_id
		);
	}

	// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.Found
	/**
	 * Set retry delay for our failed action jobs.
	 *
	 * Note: This filter receives only the retry period, not the action object.
	 * We apply our custom retry period globally for Sequensy Pro workflows.
	 *
	 * @param int $_retry_period Default retry period (unused, we override).
	 * @return int Retry period in seconds.
	 */
	public static function set_retry_delay( int $_retry_period ): int {
		// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.Found
		// Apply custom retry period for all failed actions
		// This affects the time limit before an action is marked as failed
		return 300; // 5 minutes
	}
}
