<?php

namespace AutomateWoo\Rules;

use AutomateWoo\DateTime;

defined( 'ABSPATH' ) || exit;

/**
 * Abstract date class for rules.
 *
 * @since 4.4
 */
abstract class Abstract_Date extends Rule {
	/**
	 * The type.
	 *
	 * @var string
	 */
	public $type = 'date';

	/**
	 * We use multiple values to assimilate time frame and time measure.
	 *
	 * @var bool
	 */
	public $has_multiple_value_fields = true;

	/**
	 * Is it future?
	 *
	 * @var bool
	 */
	public $has_is_future_comparision = false;

	/**
	 * Is it past?
	 *
	 * @var bool
	 */
	public $has_is_past_comparision = false;

	/**
	 * Is after specific date?
	 *
	 * @var bool
	 */
	public $has_is_after = true;

	/**
	 * Is before specific date?
	 *
	 * @var bool
	 */
	public $has_is_before = true;

	/**
	 * Is it on a specific date date?
	 *
	 * @var bool
	 */
	public $has_is_on = true;

	/**
	 * Is not on a specific date?
	 *
	 * @var bool
	 */
	public $has_is_not_on = true;

	/**
	 * Only one date per rule.
	 *
	 * @var bool
	 */
	public $is_multi = false;

	/**
	 * Is day of the week.
	 *
	 * @var bool
	 */
	public $has_days_of_the_week = true;

	/**
	 * Is between dates.
	 *
	 * @var bool
	 */
	public $has_is_between_dates = true;

	/**
	 * Is not set?
	 *
	 * @var bool
	 */
	public $has_is_not_set = true;

	/**
	 * Is set?
	 *
	 * @var bool
	 */
	public $has_is_set = true;

	/**
	 * Our rule uses datepicker?
	 *
	 * @var bool
	 */
	public $uses_datepicker = false;

	/**
	 * Does the datepicker support an optional time of day?
	 *
	 * @var bool
	 */
	public $has_time_of_day = false;

	/**
	 * Rule select options.
	 *
	 * @var array
	 */
	public $select_choices;

	/**
	 * Abstract_Date constructor.
	 */
	public function __construct() {
		if ( $this->has_is_future_comparision ) {
			$this->compare_types['is_in_the_next']     = __( 'Is in the next', 'automatewoo' );
			$this->compare_types['is_not_in_the_next'] = __( 'Is not in the next', 'automatewoo' );
		}

		if ( $this->has_is_past_comparision ) {
			$this->compare_types['is_in_the_last']     = __( 'Is in the last', 'automatewoo' );
			$this->compare_types['is_not_in_the_last'] = __( 'Is not in the last', 'automatewoo' );
		}

		if ( $this->has_is_after ) {
			$this->uses_datepicker           = true;
			$this->compare_types['is_after'] = __( 'Is after', 'automatewoo' );
		}

		if ( $this->has_is_before ) {
			$this->uses_datepicker            = true;
			$this->compare_types['is_before'] = __( 'Is before', 'automatewoo' );
		}

		if ( $this->has_is_on ) {
			$this->uses_datepicker        = true;
			$this->compare_types['is_on'] = __( 'Is on', 'automatewoo' );
		}

		if ( $this->has_is_not_on ) {
			$this->uses_datepicker             = true;
			$this->compare_types ['is_not_on'] = __( 'Is not on', 'automatewoo' );
		}

		if ( $this->has_days_of_the_week ) {
			$this->compare_types['days_of_the_week'] = __( 'Is on day/s of the week', 'automatewoo' );
		}

		if ( $this->has_is_between_dates ) {
			$this->compare_types['is_between'] = __( 'Is in the range', 'automatewoo' );
		}

		if ( $this->has_is_not_set ) {
			$this->compare_types['is_not_set'] = __( 'Is not set', 'automatewoo' );
		}

		if ( $this->has_is_set ) {
			$this->compare_types['is_set'] = __( 'Is set', 'automatewoo' );
		}

		$this->select_choices = [
			'hours' => __( 'Hours', 'automatewoo' ),
			'days'  => __( 'Calendar days', 'automatewoo' ),
		];

		parent::__construct();
	}

	/**
	 * Get a UTC boundary date for relative calendar-day comparisons.
	 *
	 * @since 6.5.0
	 *
	 * @param DateTime $date      Date in UTC.
	 * @param int      $timeframe Number of days.
	 * @param string   $direction The direction to calculate: past or future.
	 *
	 * @return DateTime
	 * @throws \Exception When the interval can't be created.
	 */
	protected function get_relative_calendar_day_boundary( $date, $timeframe, $direction ) {
		$boundary = clone $date;
		$boundary->convert_to_site_time();

		$interval = new \DateInterval( 'P' . absint( $timeframe ) . 'D' );

		if ( 'past' === $direction ) {
			$boundary->sub( $interval );
			$boundary->set_time_to_day_start();
		} else {
			$boundary->add( $interval );
			$boundary->set_time_to_day_end();
		}

		return $boundary->convert_to_utc_time();
	}

	/**
	 * Validates a timeframe between dates and that the timeframe is the expected.
	 *
	 * @param DateTime $date      The date to validate.
	 * @param string   $compare   Date compare type: is_in_the_next, is_not_in_the_next, is_in_the_last, is_not_in_the_last
	 * @param int      $timeframe The timeframe we want to validate.
	 * @param string   $measure   Days or Hours.
	 *
	 * @return bool
	 */
	protected function validate_date_diff( $date, $compare, $timeframe, $measure ) {
		try {
			$interval = new \DateInterval( 'PT' . $timeframe . 'H' );
		} catch ( \Exception $e ) {
			return false;
		}

		$now             = new DateTime( 'now' );
		$comparison_date = clone $now;

		switch ( $compare ) {
			case 'is_in_the_next':
				$comparison_date = 'days' === $measure
					? $this->get_relative_calendar_day_boundary( $now, $timeframe, 'future' )
					: $comparison_date->add( $interval );
				return $this->validate_is_between_dates( $date, $now, $comparison_date );
			case 'is_not_in_the_next':
				$comparison_date = 'days' === $measure
					? $this->get_relative_calendar_day_boundary( $now, $timeframe, 'future' )
					: $comparison_date->add( $interval );
				return ! $this->validate_is_between_dates( $date, $now, $comparison_date );
			case 'is_in_the_last':
				$comparison_date = 'days' === $measure
					? $this->get_relative_calendar_day_boundary( $now, $timeframe, 'past' )
					: $comparison_date->sub( $interval );
				return $this->validate_is_between_dates( $date, $comparison_date, $now );
			case 'is_not_in_the_last':
				$comparison_date = 'days' === $measure
					? $this->get_relative_calendar_day_boundary( $now, $timeframe, 'past' )
					: $comparison_date->sub( $interval );
				return ! $this->validate_is_between_dates( $date, $comparison_date, $now );
		}

		return false;
	}

	/**
	 * Are we running a in/not in the next/last validation?
	 *
	 * @param string $compare Compare we want to run.
	 *
	 * @return bool
	 */
	private function is_past_future_validation( $compare ) {
		return in_array(
			$compare,
			[
				'is_in_the_next',
				'is_not_in_the_next',
				'is_in_the_last',
				'is_not_in_the_last',
			],
			true
		);
	}

	/**
	 * Validates if a date is the same, based on Y-m-d format.
	 *
	 * @param DateTime $date1 First date.
	 * @param DateTime $date2 Second date for comparision.
	 * @param string   $format Date format to compare.
	 *
	 * @return bool
	 */
	private function validate_same_date( $date1, $date2, $format = 'Y-m-d' ) {
		return ( $date1->format( $format ) === $date2->format( $format ) );
	}

	/**
	 * Parses a rule's optional time of day.
	 *
	 * @param array|int $value The rule value.
	 *
	 * @return int[]|false|null
	 */
	private function parse_rule_time_of_day( $value ) {
		if ( ! $this->has_time_of_day || ! is_array( $value ) || ! array_key_exists( 'time', $value ) ) {
			return null;
		}

		$time = $value['time'];

		if ( is_string( $time ) ) {
			if ( '' === trim( $time ) ) {
				return null;
			}

			if ( ! preg_match( '/^([0-9]{1,2}):([0-9]{2})(?::[0-9]{2})?$/', $time, $matches ) ) {
				return false;
			}

			$hour   = (int) $matches[1];
			$minute = (int) $matches[2];
		} elseif ( is_array( $time ) ) {
			$hour_value   = isset( $time[0] ) ? trim( (string) $time[0] ) : '';
			$minute_value = isset( $time[1] ) ? trim( (string) $time[1] ) : '';

			if ( '' === $hour_value && '' === $minute_value ) {
				return null;
			}

			if ( ! ctype_digit( $hour_value ) || ! ctype_digit( $minute_value ) ) {
				return false;
			}

			$hour   = (int) $hour_value;
			$minute = (int) $minute_value;
		} else {
			return false;
		}

		if ( $hour < 0 || $hour > 23 || $minute < 0 || $minute > 59 ) {
			return false;
		}

		return [ $hour, $minute ];
	}

	/**
	 * Are we running a same/not same date validation?
	 *
	 * @param string $compare Compare we want to run.
	 *
	 * @return bool
	 */
	private function is_same_date_validation( $compare ) {
		return in_array(
			$compare,
			[
				'is_on',
				'is_not_on',
			],
			true
		);
	}

	/**
	 * Validates if a date is after/before a second date.
	 *
	 * @param DateTime $date1       Is date1 before/after.
	 * @param DateTime $date2       Date2?.
	 * @param string   $comparision after/before.
	 *
	 * @return bool
	 */
	private function validate_before_after_date( $date1, $date2, $comparision ) {
		if ( 'after' === $comparision ) {
			return ( $date1 > $date2 );
		}

		return ( $date1 < $date2 );
	}

	/**
	 * Are we running a before/after validation?
	 *
	 * @param string $compare Compare we want to run.
	 *
	 * @return bool
	 */
	private function is_before_after_validation( $compare ) {
		return in_array(
			$compare,
			[
				'is_after',
				'is_before',
			],
			true
		);
	}

	/**
	 * Validates that our day is of the days in the array.
	 *
	 * @param DateTime $date         Must be UTC.
	 * @param array    $days_of_week Which days of the week we want to search against.
	 *
	 * @return bool
	 */
	private function validate_is_day_of_week( $date, $days_of_week ) {
		// days of the week must be compared in the site's timezone
		$date->convert_to_site_time();

		$days_of_week = array_map( 'absint', $days_of_week );

		return in_array( absint( $date->format( 'N' ) ), $days_of_week, true );
	}

	/**
	 * Are we running a day_of_week validation?
	 *
	 * @param string $compare Compare we want to run.
	 *
	 * @return bool
	 */
	private function is_days_of_week_validation( $compare ) {
		return ( 'days_of_the_week' === $compare );
	}

	/**
	 * Validates that a date is between two other dates.
	 *
	 * All dates must be in the same timezone.
	 *
	 * @param DateTime $date Date that we are checking is between $from and $to
	 * @param DateTime $from Date we are checking from.
	 * @param DateTime $to   Date we are checking up to.
	 *
	 * @return bool
	 */
	private function validate_is_between_dates( $date, $from, $to ) {
		if ( $date < $from ) {
			return false;
		}

		if ( $date > $to ) {
			return false;
		}

		return true;
	}

	/**
	 * Are we running a between_dates validation?
	 *
	 * @param string $compare Compare we want to run.
	 *
	 * @return bool
	 */
	private function is_between_dates_validation( $compare ) {
		return ( 'is_between' === $compare );
	}

	/**
	 * Validates that we're passing a correct number of days and we're checking more than 0 days.
	 *
	 * @param string         $compare What variables we're using to compare.
	 * @param array|int      $value   The value to compare.
	 * @param DateTime|false $date    The date used for the comparision. Must be UTC.
	 *
	 * @return bool
	 */
	public function validate_date( $compare, $value, $date ) {
		// Make sure that our rule still can run this compare type.
		if ( ! array_key_exists( $compare, $this->compare_types ) ) {
			return false;
		}

		// If we have no date, pass to separate validation method
		if ( empty( $date ) ) {
			return $this->validate_logical_empty_date( $compare );
		}

		// normalize date even though it should already be an AutomateWoo\DateTime instance
		$date = aw_normalize_date( $date );

		// Validate && sanitize values.
		if ( is_array( $value ) ) {
			$rule_timeframe    = ( ! empty( $value['timeframe'] ) ) ? absint( $value['timeframe'] ) : 0;
			$rule_measure      = ( ! empty( $value['measure'] ) && 'days' === $value['measure'] ) ? $value['measure'] : 'hours';
			$rule_date         = ( ! empty( $value['date'] ) ) ? $value['date'] : '';
			$rule_days_of_week = ( ! empty( $value['dow'] ) ) ? $value['dow'] : [];
			$rule_from         = ( ! empty( $value['from'] ) ) ? $value['from'] : '';
			$rule_to           = ( ! empty( $value['to'] ) ) ? $value['to'] : '';
			$rule_time         = $this->parse_rule_time_of_day( $value );
		} else {
			$rule_timeframe    = absint( $value );
			$rule_measure      = 'hours';
			$rule_date         = '';
			$rule_days_of_week = [];
			$rule_from         = '';
			$rule_to           = '';
			$rule_time         = null;
		}

		if ( false === $rule_time ) {
			return false;
		}

		// Verify that the date is set.
		if ( $compare === 'is_set' ) {
			return $date !== false;
		}

		// Date diff. past/future.
		if ( $this->is_past_future_validation( $compare ) ) {
			if ( ! $rule_timeframe ) {
				return false;
			}
			return $this->validate_date_diff( $date, $compare, $rule_timeframe, $rule_measure );
		}

		// Before/After date.
		if ( $this->is_before_after_validation( $compare ) ) {
			if ( ! $rule_date ) {
				return false;
			}

			$rule_date   = new DateTime( $rule_date );
			$comparative = 'is_after' === $compare ? 'after' : 'before';

			if ( is_array( $rule_time ) ) {
				$rule_date->setTime( $rule_time[0], $rule_time[1], 0 );
			} elseif ( 'is_after' === $compare ) {
				// exclude the current day from after comparisons
				$rule_date->set_time_to_day_end();
			}

			// Because this date value is set in the admin it is logically in site's timezone
			// Therefore we must convert it to UTC for the comparison
			$rule_date->convert_to_utc_time();

			return $this->validate_before_after_date( $date, $rule_date, $comparative );
		}

		// Is/Is Not on same date.
		if ( $this->is_same_date_validation( $compare ) ) {
			if ( ! $rule_date ) {
				return false;
			}
			$rule_date = new DateTime( $rule_date );

			if ( is_array( $rule_time ) ) {
				$rule_date->setTime( $rule_time[0], $rule_time[1], 0 );
			}

			// We must consider that the dates are from the user perspective in this case
			// So do the comparison in the site's timezone
			$date->convert_to_site_time();

			if ( 'is_on' === $compare ) {
				return $this->validate_same_date( $date, $rule_date, is_array( $rule_time ) ? 'Y-m-d H:i' : 'Y-m-d' );
			}

			if ( 'is_not_on' === $compare ) {
				return ! $this->validate_same_date( $date, $rule_date, is_array( $rule_time ) ? 'Y-m-d H:i' : 'Y-m-d' );
			}
		}

		// Handle day of the week.
		if ( $this->is_days_of_week_validation( $compare ) ) {
			return $this->validate_is_day_of_week( $date, $rule_days_of_week );
		}

		// Handle between validation
		// This validation is inclusive meaning it starts at 00:00:00 on the 'from date' and ends at 23:59:59 on the 'to date'
		if ( $this->is_between_dates_validation( $compare ) ) {
			// require at least a from or a to date, if one isn't set it can default to now
			if ( ! $rule_from && ! $rule_to ) {
				return false;
			}
			$from = new DateTime( $rule_from );
			$to   = new DateTime( $rule_to );
			// include the full 'to' day in the date range
			$to->set_time_to_day_end();

			if ( $from > $to ) {
				return false;
			}

			// Because the date values are set in the admin it is logically in site's timezone
			// Convert the validation date to site time also
			$date->convert_to_site_time();

			return $this->validate_is_between_dates( $date, $from, $to );
		}

		return false;
	}

	/**
	 * Attempts to do a more logical validation for empty dates.
	 *
	 * This method will ALWAYS validate true when using the comparative 'is_not_in_the_last' or 'is_not_in_the_next'.
	 * This is because if you have the following rule 'workflow has not run for the customer in the last hour',
	 * and in fact the workflow has NEVER run for the customer, it's more logical for validation to be true.
	 *
	 * Logically VALID comparatives for empty dates are:
	 * - is_not_set Date is empty so 'is not set' is logically true
	 * - is_not_in_the_next|is_not_in_the_last Date is empty so it has not happened AT ALL
	 * - is_not_on Date is empty so it's 'not on' all dates, therefore validates true
	 *
	 * Logically INVALID comparatives for empty dates are:
	 * - is_after|is_before It's not before or after any date
	 * - is_on It's not 'on' any date
	 * - is_in_the_next|is_in_the_last Date has not ever happened
	 * - days_of_the_week Date didn't run on any day
	 * - is_between Date can't be between any dates.
	 *
	 * @param string $comparative The type of comparison.
	 *
	 * @return bool
	 */
	public function validate_logical_empty_date( $comparative ) {
		$valid_comparatives = [
			'is_not_set',
			'is_not_in_the_next',
			'is_not_in_the_last',
			'is_not_on',
		];

		$is_valid = in_array( $comparative, $valid_comparatives, true );

		return apply_filters( 'automatewoo/rules/validate_logical_empty_date', $is_valid, $comparative, $this );
	}
}
