<?php
/**
 * Rule engine: recursively evaluates a nested rule tree against a product.
 *
 * Tree shape:
 *   group     = { type:"group", match:"all"|"any", not:bool, children:[ node, ... ] }
 *   condition = { type:"condition", field:string, operator:string, value:mixed }
 *
 * The root node is always a group. This evaluator is the single source of truth
 * for whether a product belongs to a smart category.
 *
 * @package SmartCategories
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class SCWC_Rule_Engine
 */
class SCWC_Rule_Engine {

	/**
	 * Evaluate a rule tree against a product.
	 *
	 * @param int|WC_Product $product Product ID or object.
	 * @param array          $tree    Root group node.
	 * @return bool True when the product matches.
	 */
	public static function matches( $product, $tree ) {
		if ( ! $product instanceof WC_Product ) {
			$product = wc_get_product( $product );
		}
		if ( ! $product ) {
			return false;
		}
		if ( ! is_array( $tree ) || empty( $tree['children'] ) ) {
			// A smart category with no conditions matches nothing (fail closed).
			return false;
		}
		return self::eval_group( $product, $tree );
	}

	/**
	 * Produce a per-node pass/fail trace for a product ("why did/didn't it match").
	 *
	 * @param int|WC_Product $product Product ID or object.
	 * @param array          $tree    Root group node.
	 * @return array|null Nested trace, or null if product missing.
	 */
	public static function explain( $product, $tree ) {
		if ( ! $product instanceof WC_Product ) {
			$product = wc_get_product( $product );
		}
		if ( ! $product ) {
			return null;
		}
		return self::explain_node( $product, is_array( $tree ) ? $tree : SCWC_Term_Rules::default_tree() );
	}

	/**
	 * Recursive trace builder.
	 *
	 * @param WC_Product $product Product.
	 * @param array      $node    Node.
	 * @return array
	 */
	private static function explain_node( $product, $node ) {
		if ( ! is_array( $node ) ) {
			return array(
				'type'   => 'condition',
				'result' => false,
			);
		}
		$type = isset( $node['type'] ) ? $node['type'] : ( isset( $node['children'] ) ? 'group' : 'condition' );

		if ( 'group' === $type ) {
			$match     = ( isset( $node['match'] ) && 'any' === $node['match'] ) ? 'any' : 'all';
			$negate    = ! empty( $node['not'] );
			$childNodes = ( isset( $node['children'] ) && is_array( $node['children'] ) ) ? $node['children'] : array();
			$children  = array();
			$effective = array(); // Traces of children that carry a real constraint.
			foreach ( $childNodes as $child ) {
				$trace      = self::explain_node( $product, $child );
				$children[] = $trace;
				if ( ! self::is_effectively_empty( $child ) ) {
					$effective[] = $trace;
				}
			}

			// Mirror eval_group(): empty groups carry no constraint and fail closed,
			// so the trace agrees with matches() (the source of truth).
			if ( empty( $effective ) ) {
				$result = false;
			} else {
				if ( 'any' === $match ) {
					$result = false;
					foreach ( $effective as $ch ) {
						if ( ! empty( $ch['result'] ) ) {
							$result = true;
							break;
						}
					}
				} else {
					$result = true;
					foreach ( $effective as $ch ) {
						if ( empty( $ch['result'] ) ) {
							$result = false;
							break;
						}
					}
				}
				if ( $negate ) {
					$result = ! $result;
				}
			}

			return array(
				'type'     => 'group',
				'match'    => $match,
				'not'      => $negate,
				'result'   => $result,
				'children' => $children,
			);
		}

		$field    = isset( $node['field'] ) ? (string) $node['field'] : '';
		$operator = isset( $node['operator'] ) ? (string) $node['operator'] : '';
		$value    = isset( $node['value'] ) ? $node['value'] : null;
		$def      = SCWC_Fields::get( $field );
		$ftype    = $def ? $def['type'] : 'text';
		$flabel   = $def ? $def['label'] : $field;
		$actual   = ( '' !== $field ) ? SCWC_Fields::get_value( $product, $field ) : null;
		$result   = ( '' !== $field && '' !== $operator ) ? self::compare( $ftype, $operator, $actual, $value ) : false;

		return array(
			'type'        => 'condition',
			'field'       => $field,
			'field_label' => $flabel,
			'operator'    => $operator,
			'value'       => $value,
			'actual'      => self::stringify_actual( $actual, $ftype ),
			'result'      => $result,
		);
	}

	/**
	 * Human-friendly display of a product's extracted value.
	 *
	 * @param mixed  $value Raw value.
	 * @param string $ftype Field type.
	 * @return string
	 */
	private static function stringify_actual( $value, $ftype ) {
		if ( 'taxonomy' === $ftype && is_array( $value ) ) {
			$names = array();
			foreach ( $value as $id ) {
				$term = get_term( (int) $id );
				if ( $term && ! is_wp_error( $term ) ) {
					$names[] = $term->name;
				}
			}
			return $names ? implode( ', ', $names ) : '—';
		}
		if ( 'date' === $ftype && is_numeric( $value ) ) {
			return gmdate( 'Y-m-d H:i', (int) $value );
		}
		if ( is_bool( $value ) ) {
			return $value ? __( 'yes', 'smart-categories-for-woocommerce' ) : __( 'no', 'smart-categories-for-woocommerce' );
		}
		if ( is_array( $value ) ) {
			return implode( ', ', array_map( 'strval', $value ) );
		}
		if ( null === $value || '' === $value ) {
			return '—';
		}
		return (string) $value;
	}

	/**
	 * Evaluate a group node.
	 *
	 * @param WC_Product $product Product.
	 * @param array      $group   Group node.
	 * @return bool
	 */
	private static function eval_group( $product, $group ) {
		$match    = isset( $group['match'] ) && 'any' === $group['match'] ? 'any' : 'all';
		$children = isset( $group['children'] ) && is_array( $group['children'] ) ? $group['children'] : array();
		$negate   = ! empty( $group['not'] );

		// Drop children that carry no real constraint (empty groups, or groups
		// that only contain empty groups). An empty group is NOT vacuously true:
		// left in an "any" parent it would match every product (fail open), so it
		// is ignored entirely.
		$children = array_values(
			array_filter(
				$children,
				static function ( $child ) {
					return ! self::is_effectively_empty( $child );
				}
			)
		);

		if ( empty( $children ) ) {
			// No real constraint at all: fail closed. Negation is deliberately NOT
			// applied — "NOT (nothing)" must not resolve to "every product".
			return false;
		}

		if ( 'any' === $match ) {
			$result = false;
			foreach ( $children as $child ) {
				if ( self::eval_node( $product, $child ) ) {
					$result = true;
					break;
				}
			}
		} else {
			$result = true;
			foreach ( $children as $child ) {
				if ( ! self::eval_node( $product, $child ) ) {
					$result = false;
					break;
				}
			}
		}

		return $negate ? ! $result : $result;
	}

	/**
	 * Whether a node carries no real constraint: a condition with no field/operator,
	 * or a group whose (recursive) children are all themselves effectively empty.
	 *
	 * @param mixed $node Node.
	 * @return bool
	 */
	private static function is_effectively_empty( $node ) {
		if ( ! is_array( $node ) ) {
			return true;
		}
		$type = isset( $node['type'] ) ? $node['type'] : ( isset( $node['children'] ) ? 'group' : 'condition' );

		if ( 'group' === $type ) {
			$children = isset( $node['children'] ) && is_array( $node['children'] ) ? $node['children'] : array();
			foreach ( $children as $child ) {
				if ( ! self::is_effectively_empty( $child ) ) {
					return false;
				}
			}
			return true;
		}

		$field    = isset( $node['field'] ) ? (string) $node['field'] : '';
		$operator = isset( $node['operator'] ) ? (string) $node['operator'] : '';
		return '' === $field || '' === $operator;
	}

	/**
	 * Evaluate any node (group or condition).
	 *
	 * @param WC_Product $product Product.
	 * @param array      $node    Node.
	 * @return bool
	 */
	private static function eval_node( $product, $node ) {
		if ( ! is_array( $node ) ) {
			return false;
		}
		$type = isset( $node['type'] ) ? $node['type'] : ( isset( $node['children'] ) ? 'group' : 'condition' );

		if ( 'group' === $type ) {
			return self::eval_group( $product, $node );
		}
		return self::eval_condition( $product, $node );
	}

	/**
	 * Evaluate a single condition node.
	 *
	 * @param WC_Product $product Product.
	 * @param array      $cond    Condition node.
	 * @return bool
	 */
	private static function eval_condition( $product, $cond ) {
		$field    = isset( $cond['field'] ) ? (string) $cond['field'] : '';
		$operator = isset( $cond['operator'] ) ? (string) $cond['operator'] : '';
		$value    = isset( $cond['value'] ) ? $cond['value'] : null;

		if ( '' === $field || '' === $operator ) {
			return false;
		}

		$def           = SCWC_Fields::get( $field );
		$type          = $def ? $def['type'] : 'text';
		$product_value = SCWC_Fields::get_value( $product, $field );

		return self::compare( $type, $operator, $product_value, $value );
	}

	/**
	 * Compare a product value against a rule value using an operator.
	 *
	 * @param string $type     Field value type.
	 * @param string $operator Operator key.
	 * @param mixed  $actual   Product's value.
	 * @param mixed  $expected Rule's configured value.
	 * @return bool
	 */
	public static function compare( $type, $operator, $actual, $expected ) {
		switch ( $operator ) {
			/* ---- Presence ---- */
			case 'is_empty':
				return null === $actual || '' === $actual || array() === $actual;
			case 'is_not_empty':
				return ! ( null === $actual || '' === $actual || array() === $actual );
			case 'exists':
				return ! ( null === $actual || '' === $actual || array() === $actual );
			case 'not_exists':
				return null === $actual || '' === $actual || array() === $actual;

			/* ---- Boolean ---- */
			case 'is_true':
				return (bool) $actual === true;
			case 'is_false':
				return (bool) $actual === false;
		}

		// Taxonomy comparisons operate on term-ID arrays.
		if ( 'taxonomy' === $type ) {
			return self::compare_taxonomy( $operator, $actual, $expected );
		}

		// Date comparisons.
		if ( 'date' === $type ) {
			return self::compare_date( $operator, $actual, $expected );
		}

		// Numeric comparisons.
		if ( 'number' === $type || in_array( $operator, array( 'gt', 'lt', 'gte', 'lte', 'between', 'not_between' ), true ) ) {
			return self::compare_number( $operator, $actual, $expected );
		}

		// Default: string / select comparisons.
		return self::compare_text( $operator, $actual, $expected );
	}

	/**
	 * Text & select comparisons (case-insensitive).
	 *
	 * @param string $operator Operator.
	 * @param mixed  $actual   Product value.
	 * @param mixed  $expected Rule value (scalar or array for in/not_in).
	 * @return bool
	 */
	private static function compare_text( $operator, $actual, $expected ) {
		$haystack = is_scalar( $actual ) ? (string) $actual : '';

		// in / not_in accept an array of acceptable values.
		if ( 'in' === $operator || 'not_in' === $operator ) {
			$list  = self::to_array( $expected );
			$found = false;
			foreach ( $list as $candidate ) {
				if ( 0 === strcasecmp( $haystack, (string) $candidate ) ) {
					$found = true;
					break;
				}
			}
			return 'in' === $operator ? $found : ! $found;
		}

		$needle = is_scalar( $expected ) ? (string) $expected : '';

		switch ( $operator ) {
			case 'equals':
				return 0 === strcasecmp( $haystack, $needle );
			case 'not_equals':
				return 0 !== strcasecmp( $haystack, $needle );
			case 'contains':
				return '' !== $needle && false !== stripos( $haystack, $needle );
			case 'not_contains':
				return '' === $needle || false === stripos( $haystack, $needle );
			case 'starts_with':
				return '' !== $needle && 0 === stripos( $haystack, $needle );
			case 'not_starts_with':
				return ! ( '' !== $needle && 0 === stripos( $haystack, $needle ) );
			case 'ends_with':
				if ( '' === $needle ) {
					return false;
				}
				$len = strlen( $needle );
				return $len <= strlen( $haystack ) && 0 === strcasecmp( substr( $haystack, -$len ), $needle );
			case 'not_ends_with':
				if ( '' === $needle ) {
					return true;
				}
				$nlen = strlen( $needle );
				return ! ( $nlen <= strlen( $haystack ) && 0 === strcasecmp( substr( $haystack, -$nlen ), $needle ) );
			case 'matches_pattern':
				return self::match_wildcard( $haystack, $needle );
			case 'not_matches_pattern':
				return ! self::match_wildcard( $haystack, $needle );
			case 'regex':
				return self::match_regex( $haystack, $needle );
			case 'not_regex':
				return ! self::match_regex( $haystack, $needle );
		}
		return false;
	}

	/**
	 * Friendly wildcard match: `*` = any chars, `?` = one char, whole-string,
	 * case-insensitive. e.g. "*spor*", "pet*", "T-?-2026".
	 *
	 * @param string $haystack Value.
	 * @param string $pattern  Wildcard pattern.
	 * @return bool
	 */
	private static function match_wildcard( $haystack, $pattern ) {
		if ( '' === $pattern || strlen( $pattern ) > 200 ) {
			return false;
		}
		$escaped = preg_quote( $pattern, '~' );
		$escaped = str_replace( array( '\*', '\?' ), array( '.*', '.' ), $escaped );
		return self::safe_preg_match( '~^' . $escaped . '$~iu', $haystack );
	}

	/**
	 * Case-insensitive regex match with a length guard; invalid patterns → false.
	 *
	 * @param string $haystack Value.
	 * @param string $pattern  Regular expression (no delimiters).
	 * @return bool
	 */
	private static function match_regex( $haystack, $pattern ) {
		if ( '' === $pattern || strlen( $pattern ) > 500 ) {
			return false;
		}
		$re = '~' . str_replace( '~', '\~', $pattern ) . '~iu';
		return self::safe_preg_match( $re, $haystack );
	}

	/**
	 * Run preg_match with tightened PCRE backtrack/recursion limits so a
	 * user-supplied catastrophic pattern cannot hang the worker (ReDoS guard).
	 * On any error (including limit exceeded) returns false = no match.
	 *
	 * @param string $pattern Full delimited pattern.
	 * @param string $subject Subject.
	 * @return bool
	 */
	private static function safe_preg_match( $pattern, $subject ) {
		$prev_bt  = ini_get( 'pcre.backtrack_limit' );
		$prev_rec = ini_get( 'pcre.recursion_limit' );
		$limit    = (string) apply_filters( 'scwc_pcre_backtrack_limit', 100000 );
		ini_set( 'pcre.backtrack_limit', $limit ); // phpcs:ignore WordPress.PHP.IniSet, Squiz.PHP.DiscouragedFunctions.Discouraged -- scoped PCRE limit: the ReDoS guard for user-supplied regex rules; restored below.
		ini_set( 'pcre.recursion_limit', $limit ); // phpcs:ignore WordPress.PHP.IniSet, Squiz.PHP.DiscouragedFunctions.Discouraged -- scoped PCRE limit (ReDoS guard); restored below.
		$result = @preg_match( $pattern, $subject ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- invalid user patterns must not emit warnings; the result is validated.
		if ( false !== $prev_bt ) {
			ini_set( 'pcre.backtrack_limit', $prev_bt ); // phpcs:ignore WordPress.PHP.IniSet, Squiz.PHP.DiscouragedFunctions.Discouraged -- restoring the previous PCRE limit.
		}
		if ( false !== $prev_rec ) {
			ini_set( 'pcre.recursion_limit', $prev_rec ); // phpcs:ignore WordPress.PHP.IniSet, Squiz.PHP.DiscouragedFunctions.Discouraged -- restoring the previous PCRE limit.
		}
		return 1 === $result;
	}

	/**
	 * Numeric comparisons.
	 *
	 * @param string $operator Operator.
	 * @param mixed  $actual   Product value.
	 * @param mixed  $expected Rule value.
	 * @return bool
	 */
	private static function compare_number( $operator, $actual, $expected ) {
		if ( null === $actual || '' === $actual ) {
			return false; // No value to compare numerically.
		}
		$a = (float) $actual;

		if ( 'between' === $operator || 'not_between' === $operator ) {
			list( $min, $max ) = self::range( $expected );
			if ( null === $min || null === $max ) {
				return false;
			}
			$in = $a >= $min && $a <= $max;
			return 'between' === $operator ? $in : ! $in;
		}

		$b = (float) ( is_array( $expected ) ? reset( $expected ) : $expected );

		switch ( $operator ) {
			case 'equals':
				return abs( $a - $b ) < 0.0000001;
			case 'not_equals':
				return abs( $a - $b ) >= 0.0000001;
			case 'gt':
				return $a > $b;
			case 'lt':
				return $a < $b;
			case 'gte':
				return $a >= $b;
			case 'lte':
				return $a <= $b;
		}
		return false;
	}

	/**
	 * Taxonomy (term-ID set) comparisons.
	 *
	 * @param string $operator Operator.
	 * @param mixed  $actual   Array of the product's term IDs.
	 * @param mixed  $expected Array of selected term IDs.
	 * @return bool
	 */
	private static function compare_taxonomy( $operator, $actual, $expected ) {
		$product_terms = array_map( 'intval', self::to_array( $actual ) );
		$selected      = array_map( 'intval', self::to_array( $expected ) );

		if ( 'in_or_under' === $operator ) {
			$selected = self::expand_with_children( $selected );
			$operator = 'in';
		}

		$intersect = array_intersect( $product_terms, $selected );

		switch ( $operator ) {
			case 'in':
				return ! empty( $intersect );
			case 'not_in':
				return empty( $intersect );
			case 'contains_all':
				$unique = array_unique( $selected );
				return ! empty( $unique ) && count( array_intersect( $unique, $product_terms ) ) === count( $unique );
		}
		return false;
	}

	/**
	 * Date comparisons. Actual is a unix timestamp; expected is a date string
	 * (Y-m-d) for before/after/between, or an integer day count for in_last_days.
	 *
	 * @param string $operator Operator.
	 * @param mixed  $actual   Unix timestamp or null.
	 * @param mixed  $expected Rule value.
	 * @return bool
	 */
	private static function compare_date( $operator, $actual, $expected ) {
		if ( null === $actual || '' === $actual ) {
			return false;
		}
		$ts = (int) $actual;

		switch ( $operator ) {
			case 'in_last_days':
				$days = (int) ( is_array( $expected ) ? reset( $expected ) : $expected );
				if ( $days <= 0 ) {
					return false;
				}
				return $ts >= ( time() - $days * DAY_IN_SECONDS ) && $ts <= time();

			case 'in_last_hours':
				$hours = (int) ( is_array( $expected ) ? reset( $expected ) : $expected );
				if ( $hours <= 0 ) {
					return false;
				}
				return $ts >= ( time() - $hours * HOUR_IN_SECONDS ) && $ts <= time();

			case 'in_next_days':
				$nd = (int) ( is_array( $expected ) ? reset( $expected ) : $expected );
				if ( $nd <= 0 ) {
					return false;
				}
				return $ts >= time() && $ts <= ( time() + $nd * DAY_IN_SECONDS );

			case 'on':
				$on = self::to_timestamp( is_array( $expected ) ? reset( $expected ) : $expected );
				return null !== $on && gmdate( 'Y-m-d', $ts ) === gmdate( 'Y-m-d', $on );

			case 'on_day_of_week':
				$dows = array_map( 'intval', self::to_array( $expected ) );
				return in_array( (int) gmdate( 'w', $ts ), $dows, true );

			case 'in_month':
				$mon = (int) ( is_array( $expected ) ? reset( $expected ) : $expected );
				return $mon >= 1 && (int) gmdate( 'n', $ts ) === $mon;

			case 'on_day_of_month':
				$dom = (int) ( is_array( $expected ) ? reset( $expected ) : $expected );
				return $dom >= 1 && (int) gmdate( 'j', $ts ) === $dom;

			case 'before':
				$bound = self::to_timestamp( is_array( $expected ) ? reset( $expected ) : $expected );
				return null !== $bound && $ts < $bound;

			case 'after':
				$bound = self::to_timestamp( is_array( $expected ) ? reset( $expected ) : $expected );
				return null !== $bound && $ts > $bound;

			case 'between':
				list( $start, $end ) = self::range( $expected );
				$start_ts            = self::to_timestamp( $start );
				$end_ts              = self::to_timestamp( $end );
				if ( null === $start_ts || null === $end_ts ) {
					return false;
				}
				// Include the whole end day.
				return $ts >= $start_ts && $ts <= ( $end_ts + DAY_IN_SECONDS - 1 );
		}
		return false;
	}

	/* ---------------------------------------------------------------------
	 * Helpers.
	 * ------------------------------------------------------------------- */

	/**
	 * Coerce a value to an array.
	 *
	 * @param mixed $value Value.
	 * @return array
	 */
	private static function to_array( $value ) {
		if ( is_array( $value ) ) {
			return $value;
		}
		if ( null === $value || '' === $value ) {
			return array();
		}
		// Comma-separated string → array.
		if ( is_string( $value ) && false !== strpos( $value, ',' ) ) {
			return array_map( 'trim', explode( ',', $value ) );
		}
		return array( $value );
	}

	/**
	 * Normalise a "between" value into a [min, max] pair.
	 *
	 * @param mixed $value Array [min,max] or "min,max" string.
	 * @return array{0:?float,1:?float}|array{0:mixed,1:mixed}
	 */
	private static function range( $value ) {
		if ( is_array( $value ) ) {
			$min = isset( $value[0] ) ? $value[0] : ( isset( $value['min'] ) ? $value['min'] : null );
			$max = isset( $value[1] ) ? $value[1] : ( isset( $value['max'] ) ? $value['max'] : null );
		} elseif ( is_string( $value ) && false !== strpos( $value, ',' ) ) {
			$parts = array_map( 'trim', explode( ',', $value, 2 ) );
			$min   = $parts[0];
			$max   = isset( $parts[1] ) ? $parts[1] : null;
		} else {
			$min = null;
			$max = null;
		}

		// Numeric coercion only when both look numeric; dates handled by caller.
		if ( is_numeric( $min ) ) {
			$min = (float) $min;
		}
		if ( is_numeric( $max ) ) {
			$max = (float) $max;
		}
		return array( $min, $max );
	}

	/**
	 * Convert a date string to a unix timestamp (site timezone).
	 *
	 * @param mixed $value Date string.
	 * @return int|null
	 */
	private static function to_timestamp( $value ) {
		if ( empty( $value ) ) {
			return null;
		}
		if ( is_numeric( $value ) ) {
			return (int) $value;
		}
		$ts = strtotime( (string) $value );
		return false === $ts ? null : $ts;
	}

	/**
	 * Expand a list of term IDs to include all descendant term IDs.
	 *
	 * @param int[] $term_ids Term IDs.
	 * @return int[]
	 */
	private static function expand_with_children( $term_ids ) {
		$expanded = $term_ids;
		foreach ( $term_ids as $term_id ) {
			$term = get_term( $term_id );
			if ( $term && ! is_wp_error( $term ) ) {
				$children = get_term_children( $term_id, $term->taxonomy );
				if ( ! is_wp_error( $children ) ) {
					$expanded = array_merge( $expanded, array_map( 'intval', $children ) );
				}
			}
		}
		return array_values( array_unique( $expanded ) );
	}
}
