<?php
/**
 * Utility Class
 *
 * @package errigal
 * @since 1.1.0
 */

namespace Errigal\Shared;

defined( 'ABSPATH' ) || exit;

use Errigal\Constant\{
	Errigal_Html_Attr as Html_Attr,
	Errigal_Html_Tag as Html_Tag,
	Errigal_Shared_Selector as Selector,
	Errigal_Utility_Hook as Utility_Hook,
	Errigal_WP_Function as WP_Function,
};
use Errigal\Shared\Errigal_Validator as Validator;

/**
 * This class is in charge of providing utility functions.
 *
 * @since 1.1.0
 */
class Errigal_Utility {
	/**
	 * Callback: Key.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	public const CALLBACK_KEY = 'callback_key';

	/**
	 * Callback: Value.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	public const CALLBACK_VALUE = 'callback_Value';

	/**
	 * Separator: Empty.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	public const SEPARATOR_EMPTY = '';

	/**
	 * Separator: Equals.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	public const SEPARATOR_EQUALS = '=';

	/**
	 * Separator: Spaced equals.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	public const SEPARATOR_EQUALS_SPACED = ' = ';

	/**
	 * Separator: Comma.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	public const SEPARATOR_COMMA = ', ';

	/**
	 * Separator: Hyphen.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	public const SEPARATOR_HYPHEN = '-';

	/**
	 * Separator: Underscore.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	public const SEPARATOR_UNDERSCORE = '_';

	/**
	 * Separator: Space.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	public const SEPARATOR_SPACE = ' ';

	/**
	 * Separator: Pseudo class.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	public const SEPARATOR_PSEUDO_CLASS = ':';

	/**
	 * Regular expression: Class attribute.
	 *
	 * @since 1.2.0
	 *
	 * @var string
	 */
	private const REGEX_CLASS_ATTRIBUTE = '/(class=)([\'"])(.*?)\2/';

	/**
	 * Regular expression: Class attribute: Original string.
	 *
	 * @since 1.2.0
	 *
	 * @var int
	 */
	private const REGEX_CLASS_ATTRIBUTE_ORIGINAL = 0;

	/**
	 * Regular expression: Class attribute: (class=).
	 *
	 * @since 1.2.0
	 *
	 * @var int
	 */
	private const REGEX_CLASS_ATTRIBUTE_CLASS_OPEN = 1;

	/**
	 * Regular expression: Class attribute: ([\'"]).
	 *
	 * @since 1.2.0
	 *
	 * @var int
	 */
	private const REGEX_CLASS_ATTRIBUTE_QUOTE = 2;

	/**
	 * Regular expression: Class attribute: (.*?).
	 *
	 * @since 1.2.0
	 *
	 * @var int
	 */
	private const REGEX_CLASS_ATTRIBUTE_CLASS_VALUE = 3;

	/**
	 * Regular expression: Valid class name.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	private const REGEX_VALID_CLASS_NAME = '/^[a-zA-Z][a-zA-Z0-9_-]*[a-zA-Z0-9]$/';

	/**
	 * Regular expression: Valid selector.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	private const REGEX_VALID_SELECTOR = '/^([a-zA-Z][a-zA-Z0-9_-]*|\.[a-zA-Z][a-zA-Z0-9_-]*)(\.[a-zA-Z][a-zA-Z0-9_-]*)*(:[a-zA-Z-]+(\([^()]*\))?)?$/';

	/**
	 * Selector prefix.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	private const SELECTOR_PREFIX = '.';

	/**
	 * Adds class.
	 *
	 * @since 1.2.0
	 *
	 * @param string          $html         HTML to modify.
	 * @param string|string[] $new_classes  class name(s) to add.
	 * @param string          $target_class Optional. Class to search for in class attribute. Default empty string.
	 * @return string
	 */
	public static function add_class( $html, $new_classes, $target_class = '' ) {
		if (
			Validator::is_non_empty_string( $html ) &&
			( Validator::is_non_empty_string( $new_classes ) || Validator::is_string_list( $new_classes ) ) &&
			is_string( $target_class )
		) {
			if ( Validator::is_non_empty_string( $new_classes ) ) {
				$new_classes = explode( self::SEPARATOR_SPACE, $new_classes );
			}

			return preg_replace_callback(
				self::REGEX_CLASS_ATTRIBUTE,
				/**
				 * Callback function to add class name(s) to class attribute.
				 *
				 * @since 1.2.0
				 *
				 * @param string[]        $matches      Matches from regular expression.
				 * @param string|string[] $new_classes  class name(s) to add.
				 * @param string          $target_class Optional. Class to search for in class attribute. Default empty string.
				 * @return string
				 */
				function ( $matches ) use ( $new_classes, $target_class ) {
					if ( ! Validator::is_string_list( $matches ) ) {
						return '';
					}

					if (
						! Validator::is_string_list( $new_classes ) ||
						! is_string( $target_class )
					) {
						return $matches[ self::REGEX_CLASS_ATTRIBUTE_ORIGINAL ];
					}

					$class_open = $matches[ self::REGEX_CLASS_ATTRIBUTE_CLASS_OPEN ];
					$quote      = $matches[ self::REGEX_CLASS_ATTRIBUTE_QUOTE ];
					$classes    = explode(
						self::SEPARATOR_SPACE,
						$matches[ self::REGEX_CLASS_ATTRIBUTE_CLASS_VALUE ]
					);

					if (
						Validator::is_empty_string( $target_class ) ||
						Validator::has_string_list_item( $classes, $target_class )
					) {
						$classes = array_merge( $classes, $new_classes );
					}

					return $class_open . $quote . self::get_class( ...$classes ) . $quote;
				},
				$html
			);
		}

		return $html;
	}

	/**
	 * Gets class.
	 *
	 * @since 1.1.0
	 *
	 * @param string ...$classes Variable list of class names.
	 * @return string
	 */
	public static function get_class( ...$classes ) {
		return self::join(
			self::SEPARATOR_SPACE,
			...self::sanitise_classes( $classes )
		);
	}

	/**
	 * Sanitises classes.
	 *
	 * @since 1.1.0
	 *
	 * @param string[] $classes Class names to be sanitised.
	 * @return string[]
	 */
	public static function sanitise_classes( $classes ) {
		return self::esc_attrs(
			Html_Attr::CLASS_NAME,
			$classes
		);
	}

	/**
	 * Gets selector.
	 *
	 * @since 1.1.0
	 *
	 * @param string ...$items Variable list of CSS components.
	 * @return string
	 */
	public static function get_selector( ...$items ) {
		return self::create_selector( self::SEPARATOR_SPACE, ...$items );
	}

	/**
	 * Gets compound selector.
	 *
	 * @since 1.1.0
	 *
	 * @param string ...$items Variable list of CSS components.
	 * @return string
	 */
	public static function get_compound_selector( ...$items ) {
		return self::create_selector( self::SEPARATOR_EMPTY, ...$items );
	}

	/**
	 * Gets negation pseudo class selector.
	 *
	 * @since 1.1.0
	 *
	 * @param string $class_name Class name.
	 * @return string
	 */
	public static function get_negation_pseudo_class_selector( $class_name ) {
		if ( ! Validator::is_non_empty_string( $class_name ) ) {
			return '';
		}

		$selector = self::get_selector( $class_name );

		if ( ! Validator::is_non_empty_string( $selector ) ) {
			return '';
		}

		return ':not(' . self::get_selector( $selector ) . ')';
	}

	/**
	 * Combines selectors.
	 *
	 * @since 1.1.0
	 *
	 * @param string ...$selectors Variable list of selectors.
	 * @return string
	 */
	public static function combine_selectors( ...$selectors ) {
		return esc_attr( self::join( self::SEPARATOR_COMMA, ...$selectors ) );
	}

	/**
	 * Gets attribute value.
	 *
	 * @since 1.2.0
	 *
	 * @param string $name  Attribute name.
	 * @param mixed  $value Attribute value or array of attribute values.
	 * @return string
	 */
	public static function get_attr( $name, $value ) {
		if ( ! Validator::is_non_empty_string( $name ) ) {
				return '';
		}

		$value = self::esc_attr( $name, $value );

		if ( Html_Attr::CLASS_NAME_TEMPLATE === $name ) {
			$name = Html_Attr::CLASS_NAME;
		}

		return self::SEPARATOR_SPACE .
			self::join(
				self::SEPARATOR_EQUALS,
				esc_attr( $name ),
				is_null( $value )
					? null
					: '"' . $value . '"'
			);
	}

	/**
	 * Escapes attribute value.
	 *
	 * @since 1.2.0
	 *
	 * @param string $name  Attribute name.
	 * @param mixed  $value Attribute value or array of attribute values.
	 * @return string
	 */
	public static function esc_attr( $name, $value ) {
		return self::join(
			self::SEPARATOR_SPACE,
			...self::esc_attrs( $name, $value )
		);
	}

	/**
	 * Deeply merge one or more arrays.
	 *
	 * @since 1.2.0
	 *
	 * @param array ...$arrays Arrays to merge.
	 * @return array
	 */
	public static function array_merge_deep( ...$arrays ) {
		$merged = array();

		foreach ( $arrays as $array ) {
			if ( ! is_array( $array ) ) {
				continue;
			}

			foreach ( $array as $key => $value ) {
				if (
					isset( $merged[ $key ] ) &&
					is_array( $merged[ $key ] ) &&
					is_array( $value )
				) {
					$merged[ $key ] = self::array_merge_deep( $merged[ $key ], $value );
					continue;
				}

				$merged[ $key ] = $value;
			}
		}

		return $merged;
	}

	/**
	 * Joins array elements with string.
	 *
	 * @since 1.1.0
	 *
	 * @param string $separator Separator.
	 * @param mixed  ...$items  Variable list of strings to join.
	 * @return string
	 */
	public static function join( $separator, ...$items ) {
		if ( ! is_string( $separator ) || ! is_array( $items ) ) {
			return '';
		}

		return join( $separator, self::get_non_empty_items( $items ) );
	}

	/**
	 * Sanitises associative array.
	 *
	 * @since 1.1.0
	 *
	 * @param array $associative_array Associative array to be sanitised.
	 * @param array $args {
	 *     Optional. Associative array of arguments for sanitising functions. Default empty array.
	 *
	 *     @type string $callback_key   Optional. Function for sanitising key. Default sanitize_key.
	 *     @type string $callback_value Optional. Function for sanitising value. Default esc_html.
	 * }
	 * @return array
	 */
	public static function sanitise_associative_array( $associative_array, $args = array() ) {
		if ( ! Validator::is_associative_array( $associative_array ) ) {
			return array();
		}

		$args = wp_parse_args(
			$args,
			array(
				self::CALLBACK_KEY   => WP_Function::SANITIZE_KEY,
				self::CALLBACK_VALUE => WP_Function::ESC_HTML,
			)
		);

		$callback_key   = is_callable( $args[ self::CALLBACK_KEY ] )
			? $args[ self::CALLBACK_KEY ]
			: WP_Function::SANITIZE_KEY;
		$callback_value = is_callable( $args[ self::CALLBACK_VALUE ] )
			? $args[ self::CALLBACK_VALUE ]
			: WP_Function::ESC_HTML;

		$sanitised_associative_array = array();

		foreach ( $associative_array as $key => $value ) {
			$sanitised_key   = $callback_key( $key );
			$sanitised_value = $callback_value( $value );

			if ( is_null( $sanitised_key ) || is_null( $sanitised_value ) ) {
				continue;
			}

			$sanitised_associative_array[ $sanitised_key ] = $sanitised_value;
		}

		return $sanitised_associative_array;
	}

	/**
	 * Gets non-empty items from array.
	 *
	 * @since 1.1.0
	 *
	 * @param mixed $items Indexed array of items.
	 * @return string[]
	 */
	private static function get_non_empty_items( $items ) {
		if ( ! is_array( $items ) ) {
			return array();
		}

		return array_filter(
			$items,
			/**
			 * Determines whether item is string, or can be converted to string.
			 *
			 * @since 1.1.0
			 *
			 * @param mixed $item Variable being evaluated.
			 * @return bool
			 */
			function ( $item ) {
				return ( is_scalar( $item ) && ! Validator::is_null_or_empty( $item ) && ! Validator::is_false( $item ) ) ||
					( is_object( $item ) && method_exists( $item, '__toString' ) && '' !== (string) $item );
			}
		);
	}

	/**
	 * Creates selector.
	 *
	 * @since 1.1.0
	 *
	 * @param string $separator Separator.
	 * @param string ...$items  Variable list of CSS components.
	 * @return string
	 */
	private static function create_selector( $separator, ...$items ) {
		return esc_attr(
			self::join(
				$separator,
				...array_map(
					/**
					 * Gets selector.
					 *
					 * @since 1.1.0
					 *
					 * @param string $item Item to be converted to selector.
					 * @return string
					 */
					function ( $item ) {
						if ( ! Validator::is_non_empty_string( $item ) ) {
							return '';
						}

						$item = trim( $item );

						if ( self::is_universal_selector( $item ) ) {
							return $item;
						}

						if ( self::is_supported_selector_html_tag( $item ) ) {
							return sanitize_html_class( $item );
						}

						if ( ! self::is_valid_selector( $item ) ) {
							return '';
						}

						if ( self::is_class_name_without_prefix( $item ) ) {
							return self::SELECTOR_PREFIX . sanitize_html_class( $item );
						}

						return $item;
					},
					self::get_non_empty_items( $items )
				)
			)
		);
	}

	/**
	 * Determines whether selector is universal selector (*).
	 *
	 * @since 1.2.0
	 *
	 * @param string $selector Selector.
	 * @return bool
	 */
	private static function is_universal_selector( $selector ) {
		return Selector::UNIVERSAL === $selector;
	}

	/**
	 * Determines whether selector HTML tag is supported.
	 *
	 * @since 1.2.0
	 *
	 * @param string $html_tag HTML tag.
	 * @return bool
	 */
	private static function is_supported_selector_html_tag( $html_tag ) {
		if ( ! Validator::is_non_empty_string( $html_tag ) ) {
			return false;
		}

		/**
		 * Filters selector HTML tags.
		 *
		 * @since 1.2.0
		 *
		 * @param string[] $selector_html_tags Selector HTML tags.
		 */
		$supported_html_tags = apply_filters(
			Utility_Hook::SELECTOR_HTML_TAGS,
			array_merge(
				Html_Tag::HEADINGS,
				array( Html_Tag::A )
			)
		);

		return Validator::has_string_list_item(
			$supported_html_tags,
			$html_tag
		);
	}

	/**
	 * Determines whether selector is class name without selector prefix.
	 *
	 * @since 1.2.0
	 *
	 * @param string $class_name Class name.
	 * @return bool
	 */
	private static function is_class_name_without_prefix( $class_name ) {
		return Validator::is_non_empty_string( $class_name ) &&
			(bool) preg_match( self::REGEX_VALID_CLASS_NAME, $class_name );
	}

	/**
	 * Determines whether selector is valid.
	 *
	 * @since 1.2.0
	 *
	 * @param string $selector Selector.
	 * @return bool
	 */
	private static function is_valid_selector( $selector ) {
		return Validator::is_non_empty_string( $selector ) &&
			(bool) preg_match( self::REGEX_VALID_SELECTOR, $selector );
	}

	/**
	 * Escapes attribute values.
	 *
	 * @since 1.2.0
	 *
	 * @param string $name  Attribute name.
	 * @param mixed  $value Attribute value or array of attribute values.
	 * @return array
	 */
	private static function esc_attrs( $name, $value ) {
		if ( ! is_array( $value ) ) {
			$value = array( $value );
		}

		/**
		 * Filters HTML attribute escaping function.
		 *
		 * @since 1.2.0
		 *
		 * @param callable|null $esc_attr_function HTML attribute escaping function.
		 * @param string[]      $name              Attribute name.
		 */
		$esc_attr_function = apply_filters(
			Utility_Hook::ESC_ATTR_FUNCTION,
			self::get_esc_attr_function( $name ),
			$name,
		);

		if ( ! is_callable( $esc_attr_function ) ) {
			$esc_attr_function = WP_Function::ESC_ATTR;
		}

		return array_filter(
			array_map(
				function ( $value ) use ( $esc_attr_function ) {
					if ( is_bool( $value ) ) {
						$value = Validator::is_true( $value )
							? 'true'
							: 'false';
					}

					$value = call_user_func( $esc_attr_function, $value );

					return Validator::is_null_or_empty( $value )
						? null
						: $value;
				},
				$value
			)
		);
	}

	/**
	 * Gets esc attr function for given HTML attribute.
	 *
	 * @since 1.2.0
	 *
	 * @param string $name Attribute name.
	 * @return callable
	 */
	private static function get_esc_attr_function( $name ) {
		switch ( $name ) {
			case Html_Attr::CLASS_NAME:
				return WP_Function::SANITIZE_HTML_CLASS;
			case Html_Attr::ACTION:
			case Html_Attr::HREF:
			case Html_Attr::SRC:
				return WP_Function::ESC_URL;
			default:
				return WP_Function::ESC_ATTR;
		}
	}
}
