<?php
/**
 * Filter Evaluator
 *
 * Evaluates trigger filter conditions against context data.
 * Supports nested AND/OR groups and type-aware operators.
 *
 * @package Sequensy
 * @since 2.0.0
 */

declare(strict_types=1);

namespace Sequensy\Engine;

use Sequensy\Config\OperatorConfig;

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

/**
 * Class FilterEvaluator
 *
 * Evaluates filter conditions for workflow triggers.
 */
class FilterEvaluator {

	/**
	 * Evaluate filters against context data.
	 *
	 * @param array<string, mixed>|null $filters Filter configuration from workflow.
	 * @param array<string, mixed>      $context Trigger context data.
	 * @return bool True if filters pass (or no filters), false otherwise.
	 */
	public function evaluate( ?array $filters, array $context ): bool {
		// No filters means always pass.
		if ( empty( $filters ) ) {
			return true;
		}

		return $this->evaluate_node( $filters, $context );
	}

	/**
	 * Evaluate a single filter node (condition or group).
	 *
	 * @param array<string, mixed> $node    Filter node (condition or group).
	 * @param array<string, mixed> $context Trigger context data.
	 * @param int                  $depth   Current nesting depth (0 = root).
	 * @return bool True if node passes, false otherwise.
	 */
	private function evaluate_node( array $node, array $context, int $depth = 0 ): bool {
		// Check if this is a group (has 'logic' key).
		if ( isset( $node['logic'] ) ) {
			return $this->evaluate_group( $node, $context, $depth );
		}

		// Otherwise it's a condition.
		return $this->evaluate_condition( $node, $context );
	}

	/**
	 * Evaluate a filter group with AND/OR logic.
	 *
	 * @param array<string, mixed> $group   Filter group with logic and conditions.
	 * @param array<string, mixed> $context Trigger context data.
	 * @param int                  $depth   Current nesting depth (0 = root).
	 * @return bool True if group passes, false otherwise.
	 */
	private function evaluate_group( array $group, array $context, int $depth = 0 ): bool {
		$logic      = $group['logic'] ?? 'and';
		$conditions = $group['conditions'] ?? array();

		// Empty conditions always pass.
		if ( empty( $conditions ) ) {
			return true;
		}

		/**
		 * Maximum allowed filter nesting depth.
		 *
		 * Free: 1 (flat conditions only). Pro: 0 (unlimited).
		 *
		 * @param int $max_depth Maximum depth. 0 = unlimited.
		 */
		$max_depth = apply_filters( 'sequensy_filter_max_depth', 1 );

		foreach ( $conditions as $condition ) {
			if ( isset( $condition['logic'] ) ) {
				if ( $max_depth > 0 && ( $depth + 1 ) >= $max_depth ) {
					// Depth exceeded — treat nested group as passing to avoid
					// breaking existing workflows when Pro is deactivated.
					continue;
				}
				$result = $this->evaluate_group( $condition, $context, $depth + 1 );
			} else {
				$result = $this->evaluate_condition( $condition, $context );
			}

			// Short-circuit AND: if any condition fails, the group fails.
			if ( 'and' === $logic && ! $result ) {
				return false;
			}

			// Short-circuit OR: if any condition passes, the group passes.
			if ( 'or' === $logic && $result ) {
				return true;
			}
		}

		// AND: all passed (we didn't short-circuit).
		// OR: none passed (we didn't short-circuit).
		return 'and' === $logic;
	}

	/**
	 * Evaluate a single condition.
	 *
	 * @param array<string, mixed> $condition Filter condition with field, operator, value.
	 * @param array<string, mixed> $context   Trigger context data.
	 * @return bool True if condition passes, false otherwise.
	 */
	private function evaluate_condition( array $condition, array $context ): bool {
		$field    = $condition['field'] ?? '';
		$operator = $condition['operator'] ?? 'equals';
		$value    = $condition['value'] ?? null;

		// If no field specified, pass by default.
		if ( empty( $field ) ) {
			return true;
		}

		// Get field value from context (supports nested paths with dot notation).
		$field_value = $this->get_field_value( $context, $field );

		return $this->compare( $field_value, $operator, $value );
	}

	/**
	 * Get a field value from context, supporting dot notation for nested paths.
	 *
	 * @param array<string, mixed> $context Trigger context data.
	 * @param string               $field   Field name (can use dot notation like 'user.email').
	 * @return mixed Field value or null if not found.
	 */
	private function get_field_value( array $context, string $field ): mixed {
		// Simple case: direct key lookup.
		if ( isset( $context[ $field ] ) ) {
			return $context[ $field ];
		}

		// Support dot notation for nested fields.
		if ( str_contains( $field, '.' ) ) {
			$parts = explode( '.', $field );
			$value = $context;

			foreach ( $parts as $part ) {
				if ( is_array( $value ) && isset( $value[ $part ] ) ) {
					$value = $value[ $part ];
				} else {
					return null;
				}
			}

			return $value;
		}

		return null;
	}

	/**
	 * Compare values based on operator.
	 *
	 * @param mixed  $field_value   Value from context.
	 * @param string $operator      Comparison operator.
	 * @param mixed  $compare_value Value to compare against.
	 * @return bool True if comparison passes, false otherwise.
	 */
	private function compare( mixed $field_value, string $operator, mixed $compare_value ): bool {
		switch ( $operator ) {
			case 'equals':
				// phpcs:ignore Universal.Operators.StrictComparisons -- Intentional loose comparison for filter evaluation.
				return $field_value == $compare_value;

			case 'not_equals':
				// phpcs:ignore Universal.Operators.StrictComparisons -- Intentional loose comparison for filter evaluation.
				return $field_value != $compare_value;

			case 'contains':
				if ( is_array( $field_value ) ) {
					// phpcs:ignore WordPress.PHP.StrictInArray -- Intentional loose comparison for filter evaluation.
					return in_array( $compare_value, $field_value );
				}
				// String contains check (case-insensitive).
				return false !== stripos( (string) $field_value, (string) $compare_value );

			case 'not_contains':
				if ( is_array( $field_value ) ) {
					// phpcs:ignore WordPress.PHP.StrictInArray -- Intentional loose comparison for filter evaluation.
					return ! in_array( $compare_value, $field_value );
				}
				return false === stripos( (string) $field_value, (string) $compare_value );

			case 'greater_than':
				return (float) $field_value > (float) $compare_value;

			case 'less_than':
				return (float) $field_value < (float) $compare_value;

			case 'is_empty':
				return $this->is_empty_value( $field_value );

			case 'is_not_empty':
				return ! $this->is_empty_value( $field_value );

			default:
				/**
				 * Filter for evaluating custom operators.
				 *
				 * Allows Pro or extensions to handle custom operators.
				 * Return null to indicate the operator is not handled (defaults to true).
				 *
				 * @since 2.1.0
				 *
				 * @param bool|null $result        Evaluation result, null if not handled.
				 * @param mixed     $field_value   Value from context.
				 * @param mixed     $compare_value Value to compare against.
				 * @param string    $operator      The operator being evaluated.
				 */
				$custom_result = apply_filters(
					'sequensy_filter_evaluate_operator',
					null,
					$field_value,
					$compare_value,
					$operator
				);

				// If custom handler returned a boolean, use it.
				if ( is_bool( $custom_result ) ) {
					return $custom_result;
				}

				// Unknown operator, pass by default.
				return true;
		}
	}

	/**
	 * Check if a value is considered empty.
	 *
	 * @param mixed $value Value to check.
	 * @return bool True if empty, false otherwise.
	 */
	private function is_empty_value( mixed $value ): bool {
		if ( is_null( $value ) ) {
			return true;
		}

		if ( is_string( $value ) && '' === trim( $value ) ) {
			return true;
		}

		if ( is_array( $value ) && empty( $value ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Get valid operators for a field type.
	 *
	 * @param string $field_type Field type (string, integer, boolean, datetime, array).
	 * @return array<string> Array of valid operator names.
	 */
	public function get_operators_for_type( string $field_type ): array {
		$operators = OperatorConfig::get_operators_for_type( $field_type );

		/**
		 * Filter available filter operators for a field type.
		 *
		 * Allows Pro or extensions to add custom operators.
		 *
		 * @since 2.1.0
		 *
		 * @param array<string> $operators  Available operator names.
		 * @param string        $field_type Field type being filtered.
		 */
		return apply_filters( 'sequensy_filter_operators', $operators, $field_type );
	}
}
