<?php
/**
 * Closures class
 *
 * @package Teydea_Studio\Careers\Dependencies\Validatable_Fields
 */

namespace Teydea_Studio\Careers\Dependencies\Validatable_Fields;

use Closure;
use Teydea_Studio\Careers\Dependencies\Utils;
use WP_Error;
use WP_User_Query;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // @codeCoverageIgnore
}

/**
 * Closures class
 */
final class Closures {
	/**
	 * Return the field sanitizer closure function that allows
	 * alphanumeric characters only in the "array of strings" field
	 *
	 * @return Closure Field sanitizer closure function.
	 */
	public static function alphanumeric_array_of_strings_field_sanitizer(): Closure {
		/**
		 * Sanitizer for the "alphanumeric_array_of_strings" dynamic field
		 *
		 * @param mixed $values Values to sanitize.
		 *
		 * @return string[] Sanitized values.
		 */
		return function ( $values ): array {
			return array_map(
				function ( $value ): string {
					return Utils\Type::ensure_string( preg_replace( '/[\W]/', '', $value ) );
				},
				Utils\Type::ensure_array_of_strings( $values, [] ),
			);
		};
	}

	/**
	 * Return the character tokens field restorer and sanitizer closure function
	 *
	 * @return Closure Field restorer and sanitizer closure function.
	 */
	public static function character_tokens_field_restorer_and_sanitizer(): Closure {
		/**
		 * Restorer for the "character_tokens" dynamic field
		 *
		 * @param mixed $values Values to restore.
		 *
		 * @return string[] Restored value.
		 */
		return function ( $values ): array {
			return array_map(
				fn ( string $item ): string => mb_substr( $item, 0, 1 ),
				Utils\Type::ensure_array_of_strings( $values, [] ),
			);
		};
	}

	/**
	 * Return the character tokens field validator closure function
	 *
	 * @return Closure Field validator closure function.
	 */
	public static function character_tokens_field_validator(): Closure {
		/**
		 * Validator for the dynamic field build based on the
		 * "character tokens" field template
		 *
		 * @param mixed $values Values to validate.
		 *
		 * @return true|WP_Error Boolean "true" on success, instance of WP_Error otherwise.
		 */
		return function ( $values ) {
			if ( ! is_array( $values ) ) {
				return new WP_Error(
					'non_array_value',
					sprintf(
						// Translators: %s - type of the value given.
						__( 'Value must be an array, %s given.', 'careers' ),
						gettype( $values ),
					),
				);
			}

			foreach ( $values as $value ) {
				if ( ! is_string( $value ) || 1 !== mb_strlen( $value ) ) {
					return new WP_Error(
						'field_value_incorrect',
						sprintf(
							// Translators: %s - invalid character token value.
							__( '"%s" is not a valid character token.', 'careers' ),
							Utils\Type::ensure_string( $value ),
						),
					);
				}
			}

			return true;
		};
	}

	/**
	 * Return the restricted phrases field restorer and sanitizer closure function
	 *
	 * @return Closure Field restorer and sanitizer closure function.
	 */
	public static function restricted_phrases_field_restorer_and_sanitizer(): Closure {
		/**
		 * Restorer / sanitizer for the "restricted_phrases" field
		 *
		 * @param mixed $values Values to normalize.
		 *
		 * @return string[] Normalized values.
		 */
		return function ( $values ): array {
			$values = Utils\Type::ensure_array_of_strings( $values, [] );
			$seen   = [];
			$result = [];

			foreach ( $values as $value ) {
				$trimmed = Utils\Strings::trim( $value );

				if ( '' === $trimmed || Utils\Strings::str_starts_with( $trimmed, '#' ) ) {
					continue;
				}

				$normalized = mb_strtolower( $trimmed );

				if ( isset( $seen[ $normalized ] ) ) {
					continue;
				}

				$seen[ $normalized ] = true;
				$result[]            = $trimmed;
			}

			return $result;
		};
	}

	/**
	 * Return the restricted phrases field validator closure function
	 *
	 * @param int $max_entries      Maximum number of entries allowed in the list.
	 * @param int $max_entry_length Maximum length of a single entry, in characters.
	 *
	 * @return Closure Field validator closure function.
	 */
	public static function restricted_phrases_field_validator( int $max_entries, int $max_entry_length ): Closure {
		/**
		 * Validator for the "restricted_phrases" field
		 *
		 * @param mixed $values Values to validate.
		 *
		 * @return true|WP_Error Boolean "true" on success, instance of WP_Error otherwise.
		 */
		return function ( $values ) use ( $max_entries, $max_entry_length ) {
			if ( ! is_array( $values ) ) {
				return new WP_Error(
					'non_array_value',
					sprintf(
						// Translators: %s - type of the value given.
						__( 'Value must be an array, %s given.', 'careers' ),
						gettype( $values ),
					),
				);
			}

			$count = count( $values );

			if ( $count > $max_entries ) {
				return new WP_Error(
					'too_many_entries',
					sprintf(
						// Translators: %1$d - maximum allowed entries, %2$d - given entries.
						__( 'Too many entries; maximum allowed is %1$d, %2$d given.', 'careers' ),
						$max_entries,
						$count,
					),
				);
			}

			foreach ( $values as $value ) {
				if ( ! is_string( $value ) ) {
					return new WP_Error(
						'field_value_incorrect',
						sprintf(
							// Translators: %s - type of the value given.
							__( 'Each entry must be a string, %s given.', 'careers' ),
							gettype( $value ),
						),
					);
				}

				if ( mb_strlen( $value ) > $max_entry_length ) {
					return new WP_Error(
						'entry_too_long',
						sprintf(
							// Translators: %1$d - maximum allowed characters, %2$s - offending entry.
							__( 'Each entry must be %1$d characters or fewer; "%2$s" is too long.', 'careers' ),
							$max_entry_length,
							$value,
						),
					);
				}
			}

			return true;
		};
	}

	/**
	 * Return the field sanitizer closure function that removes
	 * disallowed characters from the user-entered CSS
	 *
	 * @return Closure Field sanitizer closure function.
	 */
	public static function css_field_sanitizer(): Closure {
		/**
		 * Sanitizer for the dynamic field build based on the
		 * "css" field template
		 *
		 * @param mixed $value Value to sanitize.
		 *
		 * @return string Sanitized value.
		 */
		return function ( $value ): string {
			return Utils\Strings::sanitize_css( $value );
		};
	}

	/**
	 * Return the field sanitizer closure function that restricts the value
	 * to a valid CSS color, emptying anything else
	 *
	 * @return Closure Field sanitizer closure function.
	 */
	public static function color_field_sanitizer(): Closure {
		/**
		 * Sanitizer for the dynamic field build based on the
		 * "color" field template
		 *
		 * @param mixed $value Value to sanitize.
		 *
		 * @return string Sanitized value.
		 */
		return function ( $value ): string {
			return Utils\Strings::sanitize_color( Utils\Type::ensure_string( $value ) );
		};
	}

	/**
	 * Return the field sanitizer closure function that restricts the value
	 * to a valid CSS box-shadow, emptying anything else
	 *
	 * @return Closure Field sanitizer closure function.
	 */
	public static function box_shadow_field_sanitizer(): Closure {
		/**
		 * Sanitizer for the dynamic field build based on the
		 * "box_shadow" field template
		 *
		 * @param mixed $value Value to sanitize.
		 *
		 * @return string Sanitized value.
		 */
		return function ( $value ): string {
			return Utils\Strings::sanitize_box_shadow( Utils\Type::ensure_string( $value ) );
		};
	}

	/**
	 * Return the date field restorer closure function
	 *
	 * @return Closure Field restorer closure function.
	 */
	public static function date_field_restorer(): Closure {
		/**
		 * Value restorer for the dynamic field build based on the
		 * "date" field template
		 *
		 * @param mixed         $value        Current value.
		 * @param ?Fields_Group $fields_group Instance of the Fields Group this field belongs to; null if field is independent.
		 *
		 * @return string Restored value.
		 */
		return function ( $value, ?Fields_Group $fields_group = null ): string {
			/** @var string $default_value */
			$default_value = null === $fields_group
				? ''
				: $fields_group->get_field_value( 'default_value' );

			if ( Utils\Type::is_date( $default_value ) ) {
				return $default_value;
			}

			/** @var bool $allow_empty */
			$allow_empty = null === $fields_group
				? true
				: $fields_group->get_field_value( 'allow_empty' );

			// Default value is incorrect; check if empty value is fine.
			if ( true === $allow_empty ) {
				return '';
			}

			return Utils\Date_Time::get_site_datetime()->format( 'Y-m-d' );
		};
	}

	/**
	 * Return the date field validator closure function
	 *
	 * @return Closure Field validator closure function.
	 */
	public static function date_field_validator(): Closure {
		/**
		 * Validator for the dynamic field build based on the
		 * "date" field template
		 *
		 * @param mixed         $value        Value to validate.
		 * @param ?Fields_Group $fields_group Instance of the Fields Group this field belongs to; null if field is independent.
		 *
		 * @return true|WP_Error Boolean "true" on success, instance of WP_Error otherwise.
		 */
		return function ( $value, ?Fields_Group $fields_group = null ) {
			if ( ! is_string( $value ) ) {
				return new WP_Error(
					'non_string_value',
					sprintf(
						// Translators: %s - type of the value given.
						__( 'Value must be a string, %s given.', 'careers' ),
						gettype( $value ),
					),
				);
			}

			/** @var bool $allow_empty */
			$allow_empty = null === $fields_group
				? true
				: $fields_group->get_field_value( 'allow_empty' );

			// Ensure empty value passes if allowed.
			if ( true === $allow_empty && '' === $value ) {
				return true;
			}

			if ( Utils\Type::is_date( $value ) ) {
				return true;
			}

			return new WP_Error(
				'field_value_incorrect',
				sprintf(
					// Translators: %s - invalid date value.
					__( '"%s" is not a valid date.', 'careers' ),
					$value,
				),
			);
		};
	}

	/**
	 * Return the media ID field validator closure function
	 *
	 * @param string[] $supported_types Array of supported media types; defaults to [ "image", "video" ].
	 *
	 * @return Closure Field validator closure function.
	 */
	public static function media_id_field_validator( array $supported_types ): Closure {
		/**
		 * Validator for the dynamic field build based on the
		 * "media_id" field template
		 *
		 * @param mixed $value Value to validate.
		 *
		 * @return true|WP_Error Boolean "true" on success, instance of WP_Error otherwise.
		 */
		return function ( $value ) use ( $supported_types ) {
			if ( ! is_int( $value ) ) {
				return new WP_Error(
					'non_integer_value',
					sprintf(
						// Translators: %s - type of the value given.
						__( 'Value must be an integer, %s given.', 'careers' ),
						gettype( $value ),
					),
				);
			}

			// Ensure empty value is always supported.
			if ( 0 === $value ) {
				return true;
			}

			$post_type = get_post_type( $value );

			if ( 'attachment' !== $post_type ) {
				return new WP_Error(
					'invalid_post_type',
					sprintf(
						// Translators: %d - post ID.
						__( 'Post with ID "%d" is not an attachment.', 'careers' ),
						$value,
					),
				);
			}

			$mime_type = get_post_mime_type( $value );

			if ( false === $mime_type ) {
				return new WP_Error(
					'unknown_mime_type',
					sprintf(
						// Translators: %d - attachment ID.
						__( 'MIME type of the attachment with ID "%d" is unknown.', 'careers' ),
						$value,
					),
				);
			}

			$type = strtok( $mime_type, '/' );

			if ( ! in_array( $type, $supported_types, true ) ) {
				return new WP_Error(
					'mime_type_incorrect',
					sprintf(
						// Translators: %d - attachment ID.
						__( 'MIME type of the attachment with ID "%d" is not supported by this field.', 'careers' ),
						$value,
					),
				);
			}

			return true;
		};
	}

	/**
	 * Unit field validator
	 *
	 * @param string[] $supported_units Array of supported units.
	 *
	 * @return Closure Field validator closure function.
	 */
	public static function unit_field_validator( array $supported_units ): Closure {
		$pattern = sprintf(
			'/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:%s)$/',
			implode(
				'|',
				array_map(
					static fn ( string $unit ): string => preg_quote( $unit, '/' ),
					$supported_units,
				),
			),
		);

		/**
		 * Validator for the dynamic field build based on the
		 * "unit" field template
		 *
		 * @param mixed $value Value to validate.
		 *
		 * @return true|WP_Error Boolean "true" on success, instance of WP_Error otherwise.
		 */
		return function ( $value ) use ( $pattern ) {
			if ( ! is_string( $value ) ) {
				return new WP_Error(
					'non_string_value',
					sprintf(
						// Translators: %s - type of the value given.
						__( 'Value must be a string, %s given.', 'careers' ),
						gettype( $value ),
					),
				);
			}

			if ( 1 !== preg_match( $pattern, $value ) ) {
				return new WP_Error(
					'field_value_incorrect',
					sprintf(
						// Translators: %s - invalid unit value.
						__( '"%s" is not a valid unit value.', 'careers' ),
						$value,
					),
				);
			}

			return true;
		};
	}

	/**
	 * Return the URL field sanitizer closure function
	 *
	 * @return Closure Field sanitizer closure function.
	 */
	public static function url_field_sanitizer(): Closure {
		/**
		 * Sanitizer for the dynamic field build based on the
		 * "url" field template
		 *
		 * @param mixed $value Value to sanitize.
		 *
		 * @return string Sanitized value.
		 */
		return function ( $value ): string {
			$value = Utils\Type::ensure_string( $value );

			if ( '' === $value ) {
				return $value;
			}

			return is_email( $value )
				? sanitize_email( $value )
				: sanitize_url( $value );
		};
	}

	/**
	 * Return the URL field validator closure function
	 *
	 * @return Closure Field validator closure function.
	 */
	public static function url_field_validator(): Closure {
		/**
		 * Validator for the dynamic field build based on the
		 * "url" field template
		 *
		 * @param mixed $value Value to validate.
		 *
		 * @return true|WP_Error Boolean "true" on success, instance of WP_Error otherwise.
		 */
		return function ( $value ) {
			if ( ! is_string( $value ) ) {
				return new WP_Error(
					'non_string_value',
					sprintf(
						// Translators: %s - type of the value given.
						__( 'Value must be a string, %s given.', 'careers' ),
						gettype( $value ),
					),
				);
			}

			if ( '' === $value || is_email( $value ) ) {
				return true;
			}

			if ( Utils\Type::is_url( $value ) ) {
				return true;
			}

			return new WP_Error(
				'field_value_incorrect',
				sprintf(
					// Translators: %s - invalid URL value.
					__( '"%s" is not a valid URL.', 'careers' ),
					$value,
				),
			);
		};
	}

	/**
	 * Return the post types field restorer closure function
	 *
	 * @param array<string,mixed> $query_args Query arguments used for getting a list of post types.
	 *
	 * @return Closure Field restorer closure function.
	 */
	public static function post_types_field_restorer( array $query_args ): Closure {
		/**
		 * Value restorer for the "post_types" field
		 *
		 * @param mixed $values Current values.
		 *
		 * @return array Restored values array.
		 */
		return function ( $values ) use ( $query_args ): array {
			if ( ! is_array( $values ) ) {
				return [];
			}

			if ( isset( $query_args['post_type__in'] ) && is_array( $query_args['post_type__in'] ) ) {
				$known_post_types = $query_args['post_type__in'];
			} else {
				/** @var array<string,string> $found_post_types */
				$found_post_types = get_post_types( $query_args );
				$known_post_types = array_values( $found_post_types );
			}

			$valid_post_types = [];

			foreach ( $values as $value ) {
				if ( in_array( $value, $known_post_types, true ) ) {
					$valid_post_types[] = $value;
				}
			}

			return $valid_post_types;
		};
	}

	/**
	 * Return the post types field validator closure function
	 *
	 * @param array<string,mixed> $query_args Query arguments used for getting a list of post types.
	 *
	 * @return Closure Field validator closure function.
	 */
	public static function post_types_field_validator( array $query_args ): Closure {
		/**
		 * Validator for the "post_types" field
		 *
		 * @param mixed $values Values to validate.
		 *
		 * @return true|WP_Error Boolean "true" on success, instance of WP_Error otherwise.
		 */
		return function ( $values ) use ( $query_args ) {
			if ( ! is_array( $values ) ) {
				return new WP_Error(
					'non_array_value',
					sprintf(
						// Translators: %s - type of the value given.
						__( 'Value must be an array, %s given.', 'careers' ),
						gettype( $values ),
					),
				);
			}

			if ( isset( $query_args['post_type__in'] ) && is_array( $query_args['post_type__in'] ) ) {
				$known_post_types = $query_args['post_type__in'];
			} else {
				/** @var array<string,string> $found_post_types */
				$found_post_types = get_post_types( $query_args );
				$known_post_types = array_values( $found_post_types );
			}

			foreach ( $values as $value ) {
				if ( ! in_array( $value, $known_post_types, true ) ) {
					/** @var string[] $known_post_types */
					return new WP_Error(
						'field_value_out_of_scope',
						sprintf(
							// Translators: %1$s - invalid value, %2$s - list of known values.
							__( '"%1$s" is not a value within "%2$s".', 'careers' ),
							Utils\Type::ensure_string( $value ),
							implode( ', ', $known_post_types ),
						),
					);
				}
			}

			return true;
		};
	}

	/**
	 * Return the user roles restorer closure function
	 *
	 * @param Utils\Users $users Users utility instance; hinted as `object` to avoid coupling this shared package to the consumer-rewritten `Utils` namespace.
	 *
	 * @return Closure Field restorer closure function.
	 */
	public static function user_roles_field_restorer( object $users ): Closure {
		/**
		 * Value restorer for the dynamic field build based on the
		 * "user_roles" field template
		 *
		 * @param mixed $values Current values.
		 *
		 * @return array Restored values array.
		 */
		return function ( $values ) use ( $users ): array {
			$known_roles = array_column( $users->get_known_user_roles(), 'value' );
			$valid_roles = [];

			if ( is_array( $values ) ) {
				foreach ( $values as $value ) {
					if ( in_array( $value, $known_roles, true ) ) {
						$valid_roles[] = $value;
					}
				}
			}

			return $valid_roles;
		};
	}

	/**
	 * Return the user roles field validator closure function
	 *
	 * @param Utils\Users $users Users utility instance; hinted as `object` to avoid coupling this shared package to the consumer-rewritten `Utils` namespace.
	 *
	 * @return Closure Field validator closure function.
	 */
	public static function user_roles_field_validator( object $users ): Closure {
		/**
		 * Validator for the dynamic field build based on the
		 * "user_roles" field template
		 *
		 * @param mixed $values Values to validate.
		 *
		 * @return true|WP_Error Boolean "true" on success, instance of WP_Error otherwise.
		 */
		return function ( $values ) use ( $users ) {
			if ( ! is_array( $values ) ) {
				return new WP_Error(
					'non_array_value',
					sprintf(
						// Translators: %s - type of the value given.
						__( 'Value must be an array, %s given.', 'careers' ),
						gettype( $values ),
					),
				);
			}

			$known_roles = array_column( $users->get_known_user_roles(), 'value' );

			foreach ( $values as $value ) {
				if ( ! in_array( $value, $known_roles, true ) ) {
					return new WP_Error(
						'field_value_out_of_scope',
						sprintf(
							// Translators: %1$s - invalid value, %2$s - list of known values.
							__( '"%1$s" is not a value within "%2$s".', 'careers' ),
							Utils\Type::ensure_string( $value ),
							implode( ', ', $known_roles ),
						),
					);
				}
			}

			return true;
		};
	}

	/**
	 * Return the users restorer closure function
	 *
	 * @return Closure Field restorer closure function.
	 */
	public static function users_field_restorer(): Closure {
		/**
		 * Value restorer for the dynamic field build based on the
		 * "users" field template
		 *
		 * @param mixed $values Current values.
		 *
		 * @return array Restored values array.
		 */
		return function ( $values ): array {
			if ( ! is_array( $values ) ) {
				return [];
			}

			$known_logins = self::resolve_known_user_logins( $values );
			$valid_users  = [];

			foreach ( $values as $value ) {
				if ( is_string( $value ) && isset( $known_logins[ $value ] ) ) {
					$valid_users[] = $value;
				}
			}

			return $valid_users;
		};
	}

	/**
	 * Return the users field validator closure function
	 *
	 * @return Closure Field validator closure function.
	 */
	public static function users_field_validator(): Closure {
		/**
		 * Validator for the dynamic field build based on the
		 * "users" field template
		 *
		 * @param mixed $values Values to validate.
		 *
		 * @return true|WP_Error Boolean "true" on success, instance of WP_Error otherwise.
		 */
		return function ( $values ) {
			if ( ! is_array( $values ) ) {
				return new WP_Error(
					'non_array_value',
					sprintf(
						// Translators: %s - type of the value given.
						__( 'Value must be an array, %s given.', 'careers' ),
						gettype( $values ),
					),
				);
			}

			$known_logins = self::resolve_known_user_logins( $values );

			foreach ( $values as $value ) {
				if ( ! is_string( $value ) ) {
					return new WP_Error(
						'field_value_incorrect',
						sprintf(
							// Translators: %s - type of the value given.
							__( 'Each entry must be a string, %s given.', 'careers' ),
							gettype( $value ),
						),
					);
				}

				if ( ! isset( $known_logins[ $value ] ) ) {
					return new WP_Error(
						'field_value_out_of_scope',
						sprintf(
							// Translators: %s - unknown user value.
							__( '"%s" does not correspond to any known user.', 'careers' ),
							Utils\Type::ensure_string( $value ),
						),
					);
				}
			}

			return true;
		};
	}

	/**
	 * Resolve which of the given values correspond to existing user logins
	 *
	 * Collapses what would otherwise be one `get_user_by()` lookup per entry into
	 * a single user query — the "users" field closures run on the settings
	 * read/write path, so a per-login lookup is an N+1 against the user table.
	 *
	 * @param mixed[] $values Values to resolve; non-string entries are ignored.
	 *
	 * @return array<string,true> Set of input logins that match an existing user, keyed by login.
	 */
	private static function resolve_known_user_logins( array $values ): array {
		$logins = array_values( array_unique( array_filter( $values, 'is_string' ) ) );

		if ( [] === $logins ) {
			return [];
		}

		$users = ( new WP_User_Query(
			[
				'count_total' => false,
				'login__in'   => $logins,
				'fields'      => [ 'user_login' ],
				'number'      => count( $logins ),
			],
		) )->get_results();

		$known_logins = [];

		foreach ( $users as $user ) {
			$known_logins[ Utils\Type::ensure_string( $user->user_login ) ] = true;
		}

		return $known_logins;
	}
}
