<?php

namespace AutomateWoo\Rules;

use AutomateWoo\Clean;
use UnexpectedValueException;

/**
 * @class Rule
 */
abstract class Rule {

	/** @var string */
	public $name;

	/** @var string */
	public $title;

	/** @var string */
	public $group;

	/** @var string string|number|object|select  */
	public $type;

	/**
	 * Define the data type used by the rule.
	 * This is a required property.
	 *
	 * @var string
	 */
	public $data_item;

	/** @var array  */
	public $compare_types = [];

	/** @var \AutomateWoo\Workflow */
	private $workflow;

	/** @var bool - e.g meta rules have 2 value fields so their value data is an stored as an array */
	public $has_multiple_value_fields = false;


	/**
	 * Constructor
	 */
	public function __construct() {
		$this->init();
		$this->determine_rule_group();
	}


	/**
	 * Init the rule.
	 */
	abstract public function init();


	/**
	 * Validates that a given workflow data item passed the rule validation
	 * based on the supplied $compare_type and $value.
	 *
	 * @param mixed  $data_item    A valid workflow data item e.g. an instance of `\WC_Order` for an order based rule.
	 * @param string $compare_type The user selected compare type for the rule.
	 * @param mixed  $value        The user entered value for the rule. This value is validated by the validate_value() method beforehand.
	 *
	 * @return bool
	 */
	abstract public function validate( $data_item, $compare_type, $value );


	/**
	 * @param \AutomateWoo\Workflow $workflow
	 */
	public function set_workflow( $workflow ) {
		$this->workflow = $workflow;
	}


	/**
	 * @return \AutomateWoo\Workflow
	 */
	public function get_workflow() {
		return $this->workflow;
	}


	/**
	 * @since 4.2
	 * @return \AutomateWoo\Data_Layer
	 */
	public function data_layer() {
		return $this->get_workflow()->data_layer();
	}


	/**
	 * Get is/is not compare types.
	 *
	 * @since 4.6
	 *
	 * @return array
	 */
	public function get_is_or_not_compare_types() {
		return [
			'is'     => __( 'is', 'automatewoo' ),
			'is_not' => __( 'is not', 'automatewoo' ),
		];
	}


	/**
	 * @return array
	 */
	public function get_string_compare_types() {
		return [
			'contains'     => __( 'contains', 'automatewoo' ),
			'not_contains' => __( 'does not contain', 'automatewoo' ),
			'is'           => __( 'is', 'automatewoo' ),
			'is_not'       => __( 'is not', 'automatewoo' ),
			'starts_with'  => __( 'starts with', 'automatewoo' ),
			'ends_with'    => __( 'ends with', 'automatewoo' ),
			'blank'        => __( 'is blank', 'automatewoo' ),
			'not_blank'    => __( 'is not blank', 'automatewoo' ),
			'regex'        => __( 'matches regex', 'automatewoo' ),
		];
	}


	/**
	 * @return array
	 */
	public function get_multi_string_compare_types() {
		return [
			'contains'    => __( 'any contains', 'automatewoo' ),
			'is'          => __( 'any matches exactly', 'automatewoo' ),
			'starts_with' => __( 'any starts with', 'automatewoo' ),
			'ends_with'   => __( 'any ends with', 'automatewoo' ),
		];
	}


	/**
	 * @return array
	 */
	public function get_float_compare_types() {
		return $this->get_is_or_not_compare_types() + [
			'greater_than' => __( 'is greater than', 'automatewoo' ),
			'less_than'    => __( 'is less than', 'automatewoo' ),
		];
	}


	/**
	 * @return array
	 */
	public function get_integer_compare_types() {
		return $this->get_float_compare_types() + [
			'multiple_of'     => __( 'is a multiple of', 'automatewoo' ),
			'not_multiple_of' => __( 'is not a multiple of', 'automatewoo' ),
		];
	}

	/**
	 * @return array
	 */
	public function get_integer_offset_compare_types() {
		return [
			'multiple_with_offset'     => __( 'is a multiple of, starting at', 'automatewoo' ),
			'not_multiple_with_offset' => __( 'is not a multiple of, starting at', 'automatewoo' ),
		];
	}

	/**
	 * Get multi-select match compare types.
	 *
	 * @since 4.6
	 *
	 * @return array
	 */
	public function get_multi_select_compare_types() {
		return [
			'matches_any'  => __( 'matches any', 'automatewoo' ),
			'matches_all'  => __( 'matches all', 'automatewoo' ),
			'matches_none' => __( 'matches none', 'automatewoo' ),
		];
	}

	/**
	 * Get includes or not includes compare types.
	 *
	 * @since 4.6
	 *
	 * @return array
	 */
	public function get_includes_or_not_compare_types() {
		return [
			'includes'     => __( 'includes', 'automatewoo' ),
			'not_includes' => __( 'does not include', 'automatewoo' ),
		];
	}

	/**
	 * @param string $compare_type
	 * @return bool
	 */
	public function is_string_compare_type( $compare_type ) {
		return array_key_exists( $compare_type, $this->get_string_compare_types() );
	}


	/**
	 * @param string $compare_type
	 * @return bool
	 */
	public function is_integer_compare_type( $compare_type ) {
		return array_key_exists( $compare_type, $this->get_integer_compare_types() )
			|| array_key_exists( $compare_type, $this->get_integer_offset_compare_types() );
	}


	/**
	 * @param string $compare_type
	 * @return bool
	 */
	public function is_float_compare_type( $compare_type ) {
		return array_key_exists( $compare_type, $this->get_float_compare_types() );
	}


	/**
	 * @param string $compare_type
	 * @return bool
	 */
	public function is_is_or_is_not_compare_type( $compare_type ) {
		return array_key_exists( $compare_type, $this->get_is_or_not_compare_types() );
	}


	/**
	 * Validate a string based rule value.
	 *
	 * @param string $actual_value
	 * @param string $compare_type
	 * @param string $expected_value
	 *
	 * @return bool
	 */
	public function validate_string( $actual_value, $compare_type, $expected_value ) {

		$actual_value   = (string) $actual_value;
		$expected_value = (string) $expected_value;

		// most comparisons are case insensitive
		$actual_value_lowercase   = strtolower( $actual_value );
		$expected_value_lowercase = strtolower( $expected_value );

		switch ( $compare_type ) {

			case 'is':
				return $actual_value_lowercase === $expected_value_lowercase;

			case 'is_not':
				return $actual_value_lowercase !== $expected_value_lowercase;

			case 'contains':
				return strstr( $actual_value_lowercase, $expected_value_lowercase ) !== false;

			case 'not_contains':
				return strstr( $actual_value_lowercase, $expected_value_lowercase ) === false;

			case 'starts_with':
				return aw_str_starts_with( $actual_value_lowercase, $expected_value_lowercase );

			case 'ends_with':
				return aw_str_ends_with( $actual_value_lowercase, $expected_value_lowercase );

			case 'blank':
				return empty( $actual_value );

			case 'not_blank':
				return ! empty( $actual_value );

			case 'regex':
				// Regex validation must not use case insensitive values
				return $this->validate_string_regex( $actual_value, $expected_value );
		}

		return false;
	}


	/**
	 * Only supports 'contains', 'is', 'starts_with', 'ends_with'
	 *
	 * @param array  $actual_values
	 * @param string $compare_type
	 * @param string $expected_value
	 * @return bool
	 */
	public function validate_string_multi( $actual_values, $compare_type, $expected_value ) {

		if ( empty( $expected_value ) ) {
			return false;
		}

		// look for at least one item that validates the text match
		foreach ( $actual_values as $coupon_code ) {
			if ( $this->validate_string( $coupon_code, $compare_type, $expected_value ) ) {
				return true;
			}
		}

		return false;
	}


	/**
	 * @param mixed  $actual_value
	 * @param string $compare_type
	 * @param mixed  $expected_value
	 * @return bool
	 */
	public function validate_number( $actual_value, $compare_type, $expected_value ) {

		if ( in_array( $compare_type, [ 'multiple_of', 'not_multiple_of', 'multiple_with_offset', 'not_multiple_with_offset' ], true ) ) {
			return $this->validate_number_multiple_of( $actual_value, $compare_type, $expected_value );
		}

		$actual_value   = (float) $actual_value;
		$expected_value = (float) $expected_value;

		switch ( $compare_type ) {

			case 'is':
				return $actual_value === $expected_value;

			case 'is_not':
				return $actual_value !== $expected_value;

			case 'greater_than':
				return $actual_value > $expected_value;

			case 'less_than':
				return $actual_value < $expected_value;

		}

		return false;
	}

	/**
	 * Validate 'multiple of' compares, only accepting integers.
	 *
	 * @param mixed  $actual_value
	 * @param string $compare_type
	 * @param mixed  $expected_value
	 *
	 * @return bool
	 */
	protected function validate_number_multiple_of( $actual_value, $compare_type, $expected_value ) {
		$offset            = 0;
		$is_offset_compare = in_array( $compare_type, [ 'multiple_with_offset', 'not_multiple_with_offset' ], true );

		if ( $is_offset_compare ) {
			if ( ! is_array( $expected_value ) ) {
				return false;
			}

			if ( ! ( array_key_exists( 'multiple', $expected_value ) || array_key_exists( 0, $expected_value ) ) ) {
				return false;
			}

			$multiple = $expected_value['multiple'] ?? $expected_value[0] ?? null;

			// Offset is optional and defaults to 0 when missing or empty.
			$offset = $expected_value['offset'] ?? $expected_value[1] ?? 0;
			if ( '' === $offset || null === $offset ) {
				$offset = 0;
			}
		} else {
			$multiple = $expected_value;
		}

		if ( ! $this->is_whole_number( $actual_value ) || ! $this->is_whole_number( $multiple ) || ! $this->is_whole_number( $offset ) ) {
			return false;
		}

		$actual_value = (int) $actual_value;
		$multiple     = (int) $multiple;
		$offset       = (int) $offset;

		if ( ! $multiple ) {
			return false;
		}

		if ( $is_offset_compare && $actual_value < $offset ) {
			return false;
		}

		$is_multiple = ( $actual_value - $offset ) % $multiple === 0;

		switch ( $compare_type ) {

			case 'multiple_of':
			case 'multiple_with_offset':
				return $is_multiple;

			case 'not_multiple_of':
			case 'not_multiple_with_offset':
				return ! $is_multiple;
		}

		return false;
	}


	/**
	 * @param mixed $number
	 * @return bool
	 */
	public function is_whole_number( $number ) {
		$number = (float) $number;
		return floor( $number ) === $number;
	}


	/**
	 * Determine the rule group based on it's title.
	 *
	 * If the group prop is already set that will be used.
	 *
	 * @since 4.5.0
	 *
	 * @return string
	 */
	public function determine_rule_group() {
		if ( isset( $this->group ) ) {
			return;
		}

		// extract the hyphenated part of the title and use as group
		if ( isset( $this->title ) && strstr( $this->title, '-' ) ) {
			list( $this->group ) = explode( ' - ', $this->title, 2 );
		}

		if ( empty( $this->group ) ) {
			$this->group = __( 'Other', 'automatewoo' );
		}
	}

	/**
	 * Validates string regex rule.
	 *
	 * @since 4.6.0
	 *
	 * @param string $string
	 * @param string $regex
	 *
	 * @return bool
	 */
	protected function validate_string_regex( $string, $regex ) {
		$regex = $this->remove_global_regex_modifier( trim( $regex ) );

		// Add '/' delimiters if none are provided in the regex.
		if ( ! preg_match( '#^/(.+)/[gi]*$#', $regex ) ) {

			// Escape any unescaped delimiters in the regex first.
			if ( preg_match( '#[^\\\\]/#', $regex ) ) {
				$regex = str_replace( '/', '\\/', $regex );
			}

			$regex = '/' . $regex . '/';
		}

		return (bool) @preg_match( $regex, $string ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- User-supplied regex may be invalid; suppression is intentional.
	}

	/**
	 * Remove the global regex modifier as it is not supported by PHP.
	 *
	 * @since 4.6.0
	 *
	 * @param string $regex
	 *
	 * @return string
	 */
	protected function remove_global_regex_modifier( $regex ) {
		return preg_replace_callback(
			'/(\/[a-z]+)$/',
			function ( $modifiers ) {
				return str_replace( 'g', '', $modifiers[0] );
			},
			$regex
		);
	}

	/**
	 * Sanitizes the rule's value.
	 *
	 * This method runs before WRITING a value to the DB but doesn't run before READING.
	 *
	 * @since 4.6.0
	 *
	 * @param mixed $value
	 *
	 * @return mixed
	 */
	public function sanitize_value( $value ) {
		return Clean::recursive( $value );
	}

	/**
	 * Formats a rule's value for display in the rules UI.
	 *
	 * @since 4.6.0
	 *
	 * @param mixed $value
	 *
	 * @return mixed
	 */
	public function format_value( $value ) {
		return $value;
	}

	/**
	 * Validate the rule's user entered value.
	 *
	 * @since 5.1.0
	 *
	 * @param mixed $value
	 *
	 * @throws UnexpectedValueException When the value is not valid.
	 */
	public function validate_value( $value ) {
		// Override this method in child classes.
	}
}
