<?php
/**
 * Defines Jetpack CRM Automation engine.
 *
 * @package automattic/jetpack-crm
 * @since 6.2.0
 */

namespace Automattic\Jetpack\CRM\Automation;

use Automattic\Jetpack\CRM\Automation\Data_Transformers\Data_Transformer_Base;
use Automattic\Jetpack\CRM\Automation\Data_Types\Data_Type;
use Automattic\Jetpack\CRM\Automation\Data_Types\Data_Type_Base;

/**
 * Automation Engine.
 *
 * @since 6.2.0
 */
class Automation_Engine {

	/**
	 * Instance singleton.
	 *
	 * @since 6.2.0
	 * @var Automation_Engine
	 */
	private static $instance = null;

	/**
	 * The triggers map name => classname.
	 *
	 * @since 6.2.0
	 * @var string[]
	 */
	private $triggers_map = array();

	/**
	 * The steps map name => classname.
	 *
	 * @since 6.2.0
	 * @var string[]
	 */
	private $steps_map = array();

	/**
	 * The Automation logger.
	 *
	 * @since 6.2.0
	 * @var ?Automation_Logger
	 */
	private $automation_logger = null;

	/**
	 * The list of registered workflows.
	 *
	 * @since 6.2.0
	 * @var Automation_Workflow[]
	 */
	private $workflows = array();

	/**
	 * An array of supported data types.
	 *
	 * @since 6.2.0
	 *
	 * @var Data_Type_Base[]
	 */
	private $data_types = array();

	/**
	 * An array of supported data transformers.
	 *
	 * @since 6.2.0
	 *
	 * @var Data_Transformer_Base[]
	 */
	private $data_transformers = array();

	/**
	 * An array of data type that represents support between types.
	 *
	 * @since 6.2.0
	 *
	 * @var string[]
	 */
	public $data_transform_map = array();

	/**
	 * Instance singleton object.
	 *
	 * @since 6.2.0
	 *
	 * @param bool $force Whether to force a new Automation_Engine instance.
	 * @return Automation_Engine The Automation_Engine instance.
	 */
	public static function instance( bool $force = false ): Automation_Engine {
		if ( ! self::$instance || $force ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * Set the automation logger.
	 *
	 * @since 6.2.0
	 *
	 * @param Automation_Logger $logger The automation logger.
	 */
	public function set_automation_logger( Automation_Logger $logger ) {
		$this->automation_logger = $logger;
	}

	/**
	 * Register data transformer.
	 *
	 * @since 6.2.0
	 *
	 * @param string $class_name The fully qualified class name for the data transformer.
	 * @return void
	 *
	 * @throws Data_Transformer_Exception Throws an exception if the data transformer class do not look valid.
	 */
	public function register_data_transformer( string $class_name ): void {
		if ( ! class_exists( $class_name ) ) {
			throw new Data_Transformer_Exception(
				sprintf( 'Data Transformer class do not exist: %s', $class_name ),
				Data_Transformer_Exception::CLASS_NOT_FOUND
			);
		}

		// Make sure that the class implements the Data_Transformer base class,
		// so we're certain that required logic exists to use the object.
		if ( ! is_subclass_of( $class_name, Data_Transformer_Base::class ) ) {
			throw new Data_Transformer_Exception(
				sprintf( 'Data Transformer class do not extend base class: %s', $class_name ),
				Data_Transformer_Exception::DO_NOT_EXTEND_BASE
			);
		}

		if ( isset( $this->data_transformers[ $class_name ] ) ) {
			throw new Data_Transformer_Exception(
				sprintf( 'Data Transformer slug already exist: %s', $class_name ),
				Data_Transformer_Exception::SLUG_EXISTS
			);
		}

		$this->data_transformers[ $class_name ] = $class_name;

		if ( ! isset( $this->data_transform_map[ $class_name::get_from() ] ) ) {
			$this->data_transform_map[ $class_name::get_from() ] = array();
		}

		$this->data_transform_map[ $class_name::get_from() ][ $class_name::get_to() ] = $class_name;
	}

	/**
	 * Register a trigger.
	 *
	 * @since 6.2.0
	 *
	 * @param string $trigger_classname Trigger classname to add to the mapping.
	 *
	 * @throws Automation_Exception Throws an exception if the trigger class does not match the expected conditions.
	 */
	public function register_trigger( string $trigger_classname ) {

		if ( ! class_exists( $trigger_classname ) ) {
			throw new Automation_Exception(
				/* Translators: %s is the name of the trigger class that does not exist. */
				sprintf( __( 'Trigger class %s does not exist', 'zero-bs-crm' ), $trigger_classname ),
				Automation_Exception::TRIGGER_CLASS_NOT_FOUND
			);
		}

		// Check if the trigger implements the interface
		if ( ! in_array( Trigger::class, class_implements( $trigger_classname ), true ) ) {
			throw new Automation_Exception(
				/* Translators: %s is the name of the trigger class that does not implement the Trigger interface. */
				sprintf( __( 'Trigger class %s does not implement the Trigger interface', 'zero-bs-crm' ), $trigger_classname ),
				Automation_Exception::TRIGGER_CLASS_NOT_FOUND
			);
		}

		// Check if the trigger has proper slug
		$trigger_slug = $trigger_classname::get_slug();

		if ( empty( $trigger_slug ) ) {
			throw new Automation_Exception(
				__( 'The trigger must have a non-empty slug', 'zero-bs-crm' ),
				Automation_Exception::TRIGGER_SLUG_EMPTY
			);
		}

		if ( array_key_exists( $trigger_slug, $this->triggers_map ) ) {
			throw new Automation_Exception(
				/* Translators: %s is the name of the trigger slug that already exists. */
				sprintf( __( 'Trigger slug already exists: %s', 'zero-bs-crm' ), $trigger_slug ),
				Automation_Exception::TRIGGER_SLUG_EXISTS
			);
		}

		$this->triggers_map[ $trigger_slug ] = $trigger_classname;
	}

	/**
	 * Register a step in the automation engine.
	 *
	 * @since 6.2.0
	 *
	 * @param string $class_name The name of the class in which the step should belong.
	 *
	 * @throws Automation_Exception Throws an exception if the step class does not exist.
	 */
	public function register_step( string $class_name ) {
		if ( ! class_exists( $class_name ) ) {
			throw new Automation_Exception(
				/* Translators: %s is the name of the step class that does not exist. */
				sprintf( __( 'Step class %s does not exist', 'zero-bs-crm' ), $class_name ),
				Step_Exception::DO_NOT_EXIST
			);
		}

		if ( ! in_array( Step::class, class_implements( $class_name ), true ) ) {
			throw new Automation_Exception(
				sprintf( 'Step class %s does not implement the Base_Step interface', $class_name ),
				Step_Exception::DO_NOT_EXTEND_BASE
			);
		}

		$step_slug                     = $class_name::get_slug();
		$this->steps_map[ $step_slug ] = $class_name;
	}

	/**
	 * Get a step class by name.
	 *
	 * @since 6.2.0
	 *
	 * @param string $step_name The name of the step whose class we will be retrieving.
	 * @return string The name of the step class.
	 *
	 * @throws Automation_Exception Throws an exception if the step class does not exist.
	 */
	public function get_step_class( string $step_name ): string {
		if ( ! isset( $this->steps_map[ $step_name ] ) ) {
			throw new Automation_Exception(
				/* Translators: %s is the name of the step class that does not exist. */
				sprintf( __( 'Step %s does not exist', 'zero-bs-crm' ), $step_name ),
				Automation_Exception::STEP_CLASS_NOT_FOUND
			);
		}
		return $this->steps_map[ $step_name ];
	}

	/**
	 * Add a workflow.
	 *
	 * @since 6.2.0
	 *
	 * @param Automation_Workflow $workflow The workflow class instance to be added.
	 * @param bool                $init_workflow Whether or not to initialize the workflow.
	 *
	 * @throws Workflow_Exception Throws an exception if the workflow is not valid.
	 */
	public function add_workflow( Automation_Workflow $workflow, bool $init_workflow = false ) {
		$workflow->set_engine( $this );

		$this->workflows[] = $workflow;

		if ( $init_workflow ) {
			$workflow->init_triggers();
		}
	}

	/**
	 * Build and add a workflow.
	 *
	 * @since 6.2.0
	 *
	 * @param array $workflow_data The workflow data to be added.
	 * @param bool  $init_workflow Whether or not to initialize the workflow.
	 * @return Automation_Workflow The workflow class instance to be added.
	 *
	 * @throws Workflow_Exception Throws an exception if the workflow is not valid.
	 */
	public function build_add_workflow( array $workflow_data, bool $init_workflow = false ): Automation_Workflow {
		$workflow = new Automation_Workflow( $workflow_data );
		$this->add_workflow( $workflow, $init_workflow );

		return $workflow;
	}

	/**
	 * Init automation workflows.
	 *
	 * @since 6.2.0
	 *
	 * @throws Workflow_Exception Throws an exception if the workflow is not valid.
	 */
	public function init_workflows() {

		/** @var Automation_Workflow $workflow */
		foreach ( $this->workflows as $workflow ) {
			$workflow->init_triggers();
		}
	}

	/**
	 * Execute workflow.
	 *
	 * @since 6.2.0
	 *
	 * @param Automation_Workflow $workflow The workflow to be executed.
	 * @param Trigger             $trigger The trigger that started the execution process.
	 * @param Data_Type           $trigger_data_type The data that was passed along by the trigger.
	 * @return bool
	 *
	 * @throws Automation_Exception Throws exception if an error executing the workflow.
	 * @throws Data_Transformer_Exception Throws exception if an error transforming the data.
	 */
	public function execute_workflow( Automation_Workflow $workflow, Trigger $trigger, Data_Type $trigger_data_type ): bool {
		$this->get_logger()->log( sprintf( 'Trigger activated: %s', $trigger->get_slug() ) );
		$this->get_logger()->log( sprintf( 'Executing workflow: %s', $workflow->name ) );

		$step_data = $workflow->get_initial_step();

		while ( $step_data ) {
			try {
				$step_class = $step_data['class_name'] ?? $this->get_step_class( $step_data['slug'] );

				if ( ! class_exists( $step_class ) ) {
					throw new Automation_Exception(
					/* Translators: %s is the name of the step class that does not exist. */
						sprintf( __( 'The step class %s does not exist.', 'zero-bs-crm' ), $step_class ),
						Automation_Exception::STEP_CLASS_NOT_FOUND
					);
				}

				/** @var Step $step */
				$step = new $step_class( $step_data );

				$step_slug = $step->get_slug();

				$this->get_logger()->log( '[' . $step->get_slug() . '] Executing step. Type: ' . $step::get_data_type() );

				$data_type = $this->maybe_transform_data_type( $trigger_data_type, $step::get_data_type() );

				$step->validate_and_execute( $data_type );

				// todo: return Step instance instead of array
				$step_id   = $step->get_next_step_id();
				$step_data = $workflow->get_step( $step_id );

				$this->get_logger()->log( '[' . $step->get_slug() . '] Step executed!' );

				if ( ! $step_data ) {
					$this->get_logger()->log( 'Workflow execution finished: No more steps found.' );
					return true;
				}
			} catch ( Automation_Exception $automation_exception ) {

				$this->get_logger()->log( 'Error executing the workflow on step: ' . $step_slug . ' - ' . $automation_exception->getMessage() );

				throw $automation_exception;
			} catch ( Data_Transformer_Exception $transformer_exception ) {
				$this->get_logger()->log( 'Error executing the workflow on step ' . $step_slug . '. Transformer error: ' . $transformer_exception->getMessage() );

				throw $transformer_exception;
			}
		}

		return false;
	}

	/**
	 * Maybe transform data type.
	 *
	 * @since 6.2.0
	 *
	 * @param Data_Type $data_type The current data type.
	 * @param string    $new_data_type_class The new data type to transform the data to.
	 * @return Data_Type The transformed data type.
	 *
	 * @throws Data_Transformer_Exception Throws an exception if the data type cannot be transformed.
	 */
	protected function maybe_transform_data_type( Data_Type $data_type, string $new_data_type_class ): Data_Type_Base {

		// Bail early if we do not have to transform the data.
		if ( $data_type instanceof $new_data_type_class ) {
			return $data_type;
		}

		$data_type_class = get_class( $data_type );

		if ( ! isset( $this->data_transform_map[ $data_type_class ][ $new_data_type_class ] ) ) {
			throw new Data_Transformer_Exception(
				sprintf( 'Transforming from "%s" to "%s" is not supported', $data_type_class, $new_data_type_class ),
				Data_Transformer_Exception::TRANSFORM_IS_NOT_SUPPORTED
			);
		}

		$transformer = new $this->data_transform_map[ $data_type_class ][ $new_data_type_class ]();

		return $transformer->transform( $data_type );
	}

	/**
	 * Get a step instance.
	 *
	 * @since 6.2.0
	 *
	 * @param array $step_data The step data hydrate the step with.
	 * @return Step A step class instance.
	 *
	 * @throws Automation_Exception Throws an exception if the step class does not exist.
	 */
	public function get_registered_step( array $step_data ): Step {

		$step_class = $this->get_step_class( $step_data['slug'] );

		if ( ! class_exists( $step_class ) ) {
			throw new Automation_Exception(
				/* Translators: %s is the name of the step class that does not exist. */
				sprintf( __( 'Step class %s does not exist', 'zero-bs-crm' ), $step_class ),
				Automation_Exception::STEP_CLASS_NOT_FOUND
			);
		}

		return new $step_class( $step_data );
	}

	/**
	 * Get registered steps.
	 *
	 * @since 6.2.0
	 *
	 * @return string[] The registered steps.
	 */
	public function get_registered_steps(): array {
		return $this->steps_map;
	}

	/**
	 * Get trigger instance.
	 *
	 * @since 6.2.0
	 *
	 * @param string $trigger_slug The name of the trigger slug with which to retrieve the trigger class.
	 * @return string The name of the trigger class.
	 *
	 * @throws Automation_Exception Throws an exception if the step class does not exist.
	 */
	public function get_trigger_class( string $trigger_slug ): string {

		if ( ! isset( $this->triggers_map[ $trigger_slug ] ) ) {
			throw new Automation_Exception(
				/* Translators: %s is the name of the step class that does not exist. */
				sprintf( __( 'Trigger %s does not exist', 'zero-bs-crm' ), $trigger_slug ),
				Automation_Exception::TRIGGER_CLASS_NOT_FOUND
			);
		}

		return $this->triggers_map[ $trigger_slug ];
	}

	/**
	 * Get Automation logger.
	 *
	 * @since 6.2.0
	 *
	 * @return Automation_Logger Return an instance of the Automation_Logger class.
	 */
	public function get_logger(): Automation_Logger {
		return $this->automation_logger ?? Automation_Logger::instance();
	}

	/**
	 * Get the registered triggers.
	 *
	 * @since 6.2.0
	 *
	 * @return string[] The registered triggers.
	 */
	public function get_registered_triggers(): array {
		return $this->triggers_map;
	}
}
