<?php
/**
 * Logical expression parsing and evaluation for conditional email tags.
 *
 * This class handles two responsibilities:
 *
 * 1. Parsing: Converts a human-readable condition string (e.g. the content
 *    between {if ...} and {/if}) into a structured array of condition
 *    triplets and logical operators.
 *
 * 2. Evaluation: Walks through the parsed structure, evaluates each
 *    condition against the registry, and combines results using AND/OR.
 *
 * Expressions are evaluated left-to-right without operator precedence.
 *
 * @package     EDD\Pro\Emails\ConditionalTags\Utilities
 * @copyright   Copyright (c) 2026, Sandhills Development, LLC
 * @license     https://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since       3.6.6
 */

namespace EDD\Pro\Emails\ConditionalTags\Utilities;

// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore

use EDD\Pro\Emails\ConditionalTags\ConditionRegistry;

/**
 * Class LogicalExpression
 *
 * @since 3.6.6
 */
class LogicalExpression {

	/**
	 * Regex pattern for a single condition token.
	 *
	 * Captures three groups:
	 *   1. The condition key (alphanumeric, underscores, hyphens).
	 *   2. The operator (>=, <=, !=, >, <, or =).
	 *   3. The quoted value (content between double quotes).
	 *
	 * The operator is always placed between the key and the opening quote.
	 * HTML entities for < and > (&lt; &gt; &amp;) are decoded before matching.
	 *
	 * @since 3.6.6
	 * @var string
	 */
	const CONDITION_PATTERN = '/^([a-zA-Z0-9_\-]+)\s*(>=|<=|!=|>|<|=)\s*"([^"]*)"$/';

	/**
	 * Parse a logical expression string into structured conditions and operators.
	 *
	 * Returns an array alternating between condition arrays and logical operators:
	 *   [ [key, operator, value], 'AND', [key], 'OR', ... ]
	 *
	 * Conditions are represented as either:
	 *   - Triplet arrays: [ 'key', 'operator', 'value' ].
	 *   - Singleton arrays: [ 'key' ] for boolean conditions.
	 *
	 * Supports:
	 *   - Explicit AND/OR between conditions.
	 *   - Implicit AND when conditions are space-separated without a logical operator.
	 *
	 * @since 3.6.6
	 *
	 * @param string $expression The raw expression string from inside {if ...}.
	 * @return array Parsed array of conditions and logical operators.
	 */
	public static function parse_expression( $expression ) {
		$expression = (string) $expression;

		/*
		 * Decode HTML entities so that operators written in email HTML
		 * (e.g. &gt;= becoming >=) are properly recognized.
		 * This is done once here so individual conditions never need to.
		 */
		$expression = html_entity_decode( $expression, ENT_QUOTES | ENT_HTML5, 'UTF-8' );

		/*
		 * Split the expression by AND/OR logical operators.
		 * The pattern captures the delimiter so we can preserve it in the result.
		 */
		$logical_pattern = '/\s+(AND|OR)\s+/i';
		$segments        = preg_split( $logical_pattern, $expression, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );
		$result          = array();

		foreach ( $segments as $segment ) {
			$segment = trim( $segment );
			if ( '' === $segment ) {
				continue;
			}

			// Preserve AND/OR operators in the result.
			if ( 'AND' === strtoupper( $segment ) || 'OR' === strtoupper( $segment ) ) {
				$result[] = strtoupper( $segment );
				continue;
			}

			/*
			 * A segment may contain multiple space-separated conditions
			 * (implicit AND). We need to split carefully so that we don't
			 * break values that might contain spaces inside quotes.
			 *
			 * Strategy: match all condition tokens using a regex that
			 * captures key + operator + "quoted value" as complete units.
			 */
			$tokens         = self::tokenize_conditions( $segment );
			$first_in_group = true;

			foreach ( $tokens as $token ) {
				if ( ! $first_in_group ) {
					$result[] = 'AND';
				}
				$result[]       = $token;
				$first_in_group = false;
			}
		}

		return $result;
	}

	/**
	 * Tokenize a segment string into individual conditions.
	 *
	 * Matches:
	 *   - Comparison conditions (triplets): key="value", key!="value", key>="value", etc.
	 *   - Boolean conditions (singletons): key
	 *
	 * Comparison matches are evaluated before boolean matches so strings like
	 * key="value" are captured as a single triplet, not as a standalone key.
	 *
	 * @since 3.6.6
	 *
	 * @param string $segment A segment of the expression (between AND/OR operators).
	 * @return array Array of condition arrays [ [key, operator, value], [key], ... ].
	 */
	private static function tokenize_conditions( $segment ) {
		$tokens = array();

		/*
		 * Match all condition patterns in source order.
		 * We use an alternation so comparison triplets are captured first.
		 *
		 * Pattern breakdown:
		 *   Comparison branch:
		 *     ([a-zA-Z0-9_\-]+)\s*(>=|<=|!=|>|<|=)\s*"([^"]*)"
		 *   Boolean branch:
		 *     ([a-zA-Z0-9_\-]+)
		 */
		$pattern = '/([a-zA-Z0-9_\-]+)\s*(>=|<=|!=|>|<|=)\s*"([^"]*)"|([a-zA-Z0-9_\-]+)/';

		if ( preg_match_all( $pattern, $segment, $matches, PREG_SET_ORDER ) ) {
			foreach ( $matches as $match ) {
				if ( ! empty( $match[1] ) ) {
					$tokens[] = array( $match[1], $match[2], $match[3] );
					continue;
				}

				if ( ! empty( $match[4] ) ) {
					$tokens[] = array( $match[4] );
				}
			}
		}

		return $tokens;
	}

	/**
	 * Evaluate a parsed logical expression using the condition registry.
	 *
	 * Walks through the parsed array from parse_expression(), evaluates each
	 * condition triplet by looking up the condition in the registry and calling
	 * its evaluate() method with the operator and value. Results are combined
	 * using AND/OR logic, evaluated left-to-right (no operator precedence).
	 *
	 * @since 3.6.6
	 *
	 * @param array             $parsed     Parsed array from parse_expression().
	 * @param ConditionRegistry $registry   The condition registry instance.
	 * @param int               $object_id  The EDD object ID (e.g. order ID).
	 * @param object            $edd_object The EDD object (e.g. Order).
	 * @param object            $email      The EDD email object.
	 * @param mixed             $context    The email context (e.g. 'order', 'customer').
	 * @return bool True if the overall expression evaluates to true.
	 */
	public static function evaluate_expression( $parsed, ConditionRegistry $registry, $object_id, $edd_object, $email, $context = '' ) {
		$result     = null;
		$pending_op = null;

		foreach ( (array) $parsed as $item ) {
			// Logical operators (AND/OR) are stored as strings between condition arrays.
			if ( is_string( $item ) && ( 'AND' === $item || 'OR' === $item ) ) {
				$pending_op = $item;
				continue;
			}

			// Triplet condition: [ key, operator, value ].
			if ( is_array( $item ) && 3 === count( $item ) ) {
				list( $key, $operator, $value ) = $item;

				// Look up the condition handler from the registry.
				$condition = $registry->get( $key, $context );

				/*
				 * Evaluate the condition, passing the operator so the condition
				 * can perform the appropriate comparison (=, !=, >, >=, <, <=).
				 * If no condition is registered for this key, default to false.
				 */
				$eval = $condition
					? $condition->evaluate( $object_id, $edd_object, $email, $value, $operator, $context )
					: false;

				// Combine with the running result using the pending logical operator.
				if ( null === $result ) {
					$result = $eval;
				} elseif ( 'OR' === $pending_op ) {
					$result = $result || $eval;
				} else {
					// Default to AND (covers explicit AND and implicit AND).
					$result = $result && $eval;
				}

				$pending_op = null;
				continue;
			}

			// Singleton condition: [ key ].
			if ( is_array( $item ) && 1 === count( $item ) ) {
				list( $key ) = $item;

				$condition = $registry->get( $key, $context );
				$eval      = $condition
					? $condition->evaluate( $object_id, $edd_object, $email, null, null, $context )
					: false;

				if ( null === $result ) {
					$result = $eval;
				} elseif ( 'OR' === $pending_op ) {
					$result = $result || $eval;
				} else {
					$result = $result && $eval;
				}

				$pending_op = null;
			}
		}

		return (bool) $result;
	}
}
