<?php
/**
 * AND/OR rule evaluation for location and conditional logic.
 *
 * @package CustomFields
 * @since 1.3.0
 */

namespace CustomFields;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Evaluates rule groups against a context bag.
 */
final class RuleEngine {

	/**
	 * Whether a rule group matches the context.
	 *
	 * @param array<string, mixed> $group   Rule group with match + rules keys.
	 * @param array<string, mixed> $context Context values.
	 * @param bool                 $relaxed Skip rules that need a saved object ID.
	 */
	public static function matches( array $group, array $context, bool $relaxed = false ): bool {
		$rules = isset( $group['rules'] ) && is_array( $group['rules'] ) ? $group['rules'] : [];
		if ( $rules === [] ) {
			return false;
		}

		$match   = (string) ( $group['match'] ?? 'all' );
		$results = [];

		foreach ( $rules as $rule ) {
			if ( ! is_array( $rule ) ) {
				continue;
			}
			$param = (string) ( $rule['param'] ?? '' );
			if ( $relaxed && in_array( $param, Config::strictLocationParams(), true ) ) {
				continue;
			}
			$results[] = self::evaluateRule( $rule, $context );
		}

		if ( $results === [] ) {
			return false;
		}

		return $match === 'any'
			? in_array( true, $results, true )
			: ! in_array( false, $results, true );
	}

	/**
	 * @param array<string, mixed> $rule    Single rule.
	 * @param array<string, mixed> $context Context bag.
	 */
	public static function evaluateRule( array $rule, array $context ): bool {
		$param    = (string) ( $rule['param'] ?? '' );
		$operator = (string) ( $rule['operator'] ?? '==' );
		$expected = (string) ( $rule['value'] ?? '' );
		$actual   = self::contextValue( $param, $context );

		if ( $param === 'user_role' ) {
			return self::compareUserRole( $actual, $operator, $expected );
		}

		if ( $param === 'post_taxonomy' ) {
			return self::matchPostTaxonomy( $rule, $context );
		}

		if ( $param === 'post_term' ) {
			return self::comparePostTerm( $actual, $operator, $expected );
		}

		return self::compare( $actual, $operator, $expected );
	}

	/**
	 * Evaluate field conditional rules against current field values.
	 *
	 * @param array<string, mixed> $conditional Conditional definition.
	 * @param array<string, mixed> $values      field_key => value map.
	 */
	public static function fieldVisible( array $conditional, array $values ): bool {
		if ( empty( $conditional['enabled'] ) ) {
			return true;
		}

		$rules = isset( $conditional['rules'] ) && is_array( $conditional['rules'] ) ? $conditional['rules'] : [];
		if ( $rules === [] ) {
			return true;
		}

		$group = [
			'match' => (string) ( $conditional['match'] ?? 'all' ),
			'rules' => [],
		];

		foreach ( $rules as $rule ) {
			if ( ! is_array( $rule ) ) {
				continue;
			}
			$field_key = (string) ( $rule['field'] ?? '' );
			$group['rules'][] = [
				'param'    => 'field',
				'operator' => (string) ( $rule['operator'] ?? '==' ),
				'value'    => (string) ( $rule['value'] ?? '' ),
				'field'    => $field_key,
			];
		}

		$context = [ 'fields' => $values ];

		return self::matches( $group, $context, false );
	}

	/**
	 * @param string               $param   Parameter name.
	 * @param array<string, mixed> $context Context bag.
	 */
	private static function contextValue( string $param, array $context ): string {
		if ( $param === 'field' ) {
			$fields = isset( $context['fields'] ) && is_array( $context['fields'] ) ? $context['fields'] : [];
			$key    = (string) ( $context['__field'] ?? '' );

			return self::stringifyValue( $fields[ $key ] ?? '' );
		}

		return self::stringifyValue( $context[ $param ] ?? '' );
	}

	/**
	 * @param mixed $value Raw value.
	 */
	private static function stringifyValue( mixed $value ): string {
		if ( is_array( $value ) ) {
			if ( $value === [] ) {
				return '';
			}
			return implode( ',', array_map( 'strval', $value ) );
		}
		if ( is_bool( $value ) ) {
			return $value ? '1' : '0';
		}

		return (string) $value;
	}

	/**
	 * @param string $actual   Comma-separated role slugs.
	 * @param string $operator Operator key.
	 * @param string $expected Role slug to compare.
	 */
	private static function compareUserRole( string $actual, string $operator, string $expected ): bool {
		$roles = array_values( array_filter( array_map( 'trim', explode( ',', $actual ) ) ) );

		return match ( $operator ) {
			'!='           => ! in_array( $expected, $roles, true ),
			'contains'     => $expected !== '' && in_array( $expected, $roles, true ),
			'not_contains' => $expected === '' || ! in_array( $expected, $roles, true ),
			'empty'        => $roles === [],
			'not_empty'    => $roles !== [],
			default        => in_array( $expected, $roles, true ),
		};
	}

	/**
	 * @param string $actual   Comma-separated term IDs on the post.
	 * @param string $operator Operator key.
	 * @param string $expected Term ID to compare.
	 */
	private static function comparePostTerm( string $actual, string $operator, string $expected ): bool {
		$terms = array_values( array_filter( array_map( 'trim', explode( ',', $actual ) ) ) );

		return match ( $operator ) {
			'!='           => ! in_array( $expected, $terms, true ),
			'contains'     => $expected !== '' && in_array( $expected, $terms, true ),
			'not_contains' => $expected === '' || ! in_array( $expected, $terms, true ),
			'empty'        => $terms === [],
			'not_empty'    => $terms !== [],
			default        => in_array( $expected, $terms, true ),
		};
	}

	/**
	 * Match a post against a specific taxonomy term.
	 *
	 * @param array<string, mixed> $rule    Location rule.
	 * @param array<string, mixed> $context Context bag.
	 */
	private static function matchPostTaxonomy( array $rule, array $context ): bool {
		$operator = (string) ( $rule['operator'] ?? '==' );
		$expected = (string) ( $rule['value'] ?? '' );
		$term_id  = absint( $expected );

		if ( $term_id <= 0 ) {
			return match ( $operator ) {
				'!=', 'not_contains', 'not_empty' => true,
				default => false,
			};
		}

		$term = get_term( $term_id );
		if ( ! $term instanceof \WP_Term ) {
			return match ( $operator ) {
				'!=', 'not_contains' => true,
				default => false,
			};
		}

		$post_id = absint( (string) ( $context['post_id'] ?? '0' ) );
		if ( $post_id <= 0 ) {
			return match ( $operator ) {
				'!=', 'not_contains', 'empty' => true,
				default => false,
			};
		}

		$post_terms = wp_get_post_terms( $post_id, $term->taxonomy, [ 'fields' => 'ids' ] );
		if ( ! is_array( $post_terms ) ) {
			$post_terms = [];
		}
		if ( $post_terms === [] && $term->taxonomy === 'category' ) {
			$post_terms = [ 1 ];
		}

		$actual = implode( ',', array_map( 'strval', $post_terms ) );

		return self::comparePostTerm( $actual, $operator, (string) $term_id );
	}

	/**
	 * @param string $actual   Actual value.
	 * @param string $operator Operator key.
	 * @param string $expected Expected value.
	 */
	private static function compare( string $actual, string $operator, string $expected ): bool {
		switch ( $operator ) {
			case '!=':
				return $actual !== $expected;
			case '>':
				return is_numeric( $actual ) && is_numeric( $expected ) && (float) $actual > (float) $expected;
			case '>=':
				return is_numeric( $actual ) && is_numeric( $expected ) && (float) $actual >= (float) $expected;
			case '<':
				return is_numeric( $actual ) && is_numeric( $expected ) && (float) $actual < (float) $expected;
			case '<=':
				return is_numeric( $actual ) && is_numeric( $expected ) && (float) $actual <= (float) $expected;
			case 'contains':
				return $expected !== '' && str_contains( $actual, $expected );
			case 'not_contains':
				return $expected === '' || ! str_contains( $actual, $expected );
			case 'empty':
				return $actual === '' || $actual === '0';
			case 'not_empty':
				return $actual !== '' && $actual !== '0';
			case '==':
			default:
				return $actual === $expected;
		}
	}

	/**
	 * Evaluate a field conditional rule (uses field key on the rule).
	 *
	 * @param array<string, mixed> $rule    Rule with field key.
	 * @param array<string, mixed> $context Must include fields map.
	 */
	public static function evaluateFieldRule( array $rule, array $context ): bool {
		$fields = isset( $context['fields'] ) && is_array( $context['fields'] ) ? $context['fields'] : [];
		$key    = (string) ( $rule['field'] ?? '' );
		$actual = self::stringifyValue( $fields[ $key ] ?? '' );

		return self::compare( $actual, (string) ( $rule['operator'] ?? '==' ), (string) ( $rule['value'] ?? '' ) );
	}

	/**
	 * Match conditional rules (field visibility).
	 *
	 * @param array<string, mixed> $conditional Conditional block.
	 * @param array<string, mixed> $values      field_key => value.
	 */
	public static function matchesConditional( array $conditional, array $values ): bool {
		if ( empty( $conditional['enabled'] ) ) {
			return true;
		}

		$rules = isset( $conditional['rules'] ) && is_array( $conditional['rules'] ) ? $conditional['rules'] : [];
		if ( $rules === [] ) {
			return true;
		}

		$match   = (string) ( $conditional['match'] ?? 'all' );
		$results = [];

		foreach ( $rules as $rule ) {
			if ( ! is_array( $rule ) ) {
				continue;
			}
			$results[] = self::evaluateFieldRule( $rule, [ 'fields' => $values ] );
		}

		return $match === 'any'
			? in_array( true, $results, true )
			: ! in_array( false, $results, true );
	}
}
