<?php
/**
 * Responsible for different user's manipulations.
 *
 * @package    wp2fa
 * @subpackage user-utils
 *
 * @copyright  2026 Melapress
 * @license    https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
 *
 * @see       https://wordpress.org/plugins/wp-2fa/
 */

declare(strict_types=1);

namespace WP2FA\Utils;

use WP2FA\Methods\Backup_Codes;
use WP2FA\Admin\Helpers\User_Helper;

if ( ! class_exists( '\WP2FA\Utils\User_Utils' ) ) {
	/**
	 * Utility class for creating modal popup markup.
	 *
	 * @package WP2FA\Utils
	 *
	 * @since 1.4.2
	 */
	class User_Utils {
		/**
		 * Holds map with human readable 2FA statuses.
		 *
		 * @var array
		 *
		 * @since 2.2.0
		 */
		private static $statuses;

		/**
		 * Determines the proper 2FA status of the given user.
		 *
		 * @param \WP_User $user - The user to check.
		 *
		 * @return array
		 *
		 * @since 2.2.0
		 */
		public static function determine_user_2fa_status( $user ) {
			// Get current user, we going to need this regardless.
			$current_user = \wp_get_current_user();

			// Bail if we still don't have an object.
			if ( ! is_a( $user, '\WP_User' ) || ! is_a( $current_user, '\WP_User' ) ) {
				return array();
			}

			$roles = (array) $user->roles;

			// Grab grace period UNIX time.
			$grace_period_expired = User_Helper::get_grace_period( $user );
			$is_user_excluded     = User_Helper::is_excluded( $user->ID );
			$is_user_enforced     = User_Helper::is_enforced( $user->ID );
			$is_user_locked       = User_Helper::is_user_locked( $user->ID );
			$user_last_login      = User_Helper::get_login_date_for_user( $user->ID );

			// First let's see if the user already has a token.
			$enabled_methods = User_Helper::get_enabled_method_for_user( $user );

			$no_enforced_methods = false;
			if ( 'do-not-enforce' === Settings_Utils::get_setting_role( User_Helper::get_user_role( $user ), 'enforcement-policy' ) ) {
				/**
				 * Filter that gives methods ability to make themselves enforced even the global enforcement is off.
				 *
				 * @param bool - at this point this is true.
				 *
				 * @since 3.0.0
				 */
				$no_enforced_methods = \apply_filters( WP_2FA_PREFIX . 'is_method_enforced', true, $user );
			}

			$user_type = array();
			// Order is important here - for speed optimizations see self::extract_statuses() function of that class - we probably need to redo the whole thing.
			if ( $no_enforced_methods && ! empty( $enabled_methods ) ) {
				$user_type[] = 'no_required_has_enabled';
			}

			if ( $no_enforced_methods && empty( $enabled_methods ) && ! $is_user_excluded ) {
				if ( empty( $user_last_login ) ) {
					$user_type[] = User_Helper::USER_UNDETERMINED_STATUS;
				} else {
					$user_type[] = 'no_required_not_enabled';
				}
			}

			if ( ! $no_enforced_methods && empty( $enabled_methods ) && ! $is_user_excluded && $is_user_enforced ) {
				$user_type[] = 'user_needs_to_setup_2fa';
			}

			if ( ! $no_enforced_methods && empty( $enabled_methods ) && ! $is_user_excluded && ! $is_user_enforced ) {
				if ( empty( $user_last_login ) ) {
					$user_type[] = User_Helper::USER_UNDETERMINED_STATUS;
				} else {
					$user_type[] = 'no_required_not_enabled';
				}
			}

			if ( $is_user_excluded ) {
				$user_type[] = 'user_is_excluded';
			}

			if ( $is_user_locked ) {
				$user_type[] = 'user_is_locked';
			}

			if ( ! empty( $enabled_methods ) ) {
				$user_type[] = 'has_enabled_methods';
			}

			$codes_remaining = Backup_Codes::codes_remaining_for_user( $user );
			if ( 0 === $codes_remaining ) {
				$user_type[] = 'user_needs_to_setup_backup_codes';
			}

			if ( empty( $roles ) ) {
				$user_type[] = 'orphan_user'; // User has no role.
			}

			if ( \current_user_can( 'manage_options' ) ) {
				$user_type[] = 'can_manage_options';
			}

			if ( \current_user_can( 'read' ) ) {
				$user_type[] = 'can_read';
			}

			if ( $grace_period_expired ) {
				$user_type[] = 'grace_has_expired';
			}

			if ( $current_user->ID === $user->ID ) {
				$user_type[] = 'viewing_own_profile';
			}

			/*
			 * Gives the ability to alter the user types for the user.
			 *
			 * @param array $user_type - Type of the user.
			 * @param \WP_User $user - The WP user.
			 *
			 * @since 2.0.0
			 */
			return \apply_filters( WP_2FA_PREFIX . 'additional_user_types', $user_type, $user );
		}

		/**
		 * Checks is all values exist in given array.
		 *
		 * @param array $needles  - Which values to check.
		 * @param array $haystack - The array to check against.
		 *
		 * @return bool
		 *
		 * @since 2.2.0
		 */
		public static function in_array_all( $needles, $haystack ) {
			return empty( array_diff( $needles, $haystack ) );
		}

		/**
		 * Check if role is not in given array of roles.
		 *
		 * @param array $roles      - All roles.
		 * @param array $user_roles - The User roles.
		 *
		 * @return bool
		 *
		 * @since 2.2.0
		 */
		public static function role_is_not( $roles, $user_roles ) {
			if ( empty( array_intersect( $roles, $user_roles ) ) ) {
				return true;
			}

			return false;
		}

		/**
		 * Return all users, either by using a direct query or get_users.
		 *
		 * @param string $method     Method to use.
		 * @param array  $users_args Query arguments.
		 *
		 * @return mixed Array of IDs/Object of Users.
		 *
		 * @since 2.2.0
		 */
		private static function get_all_users_data( $method, $users_args ) {
			if ( 'get_users' === $method ) {
				return get_users( $users_args );
			}

			// method is "query", let's build the SQL query ourselves using prepared statements.
			global $wpdb;

			$batch_size = isset( $users_args['batch_size'] ) ? (int) $users_args['batch_size'] : false;
			$offset     = isset( $users_args['count'] ) ? (int) $users_args['count'] * $batch_size : false;

			// Base select.
			$select = "SELECT ID, user_login FROM {$wpdb->users} u";
			$params = array();

			// If we want to grab users with a specific role.
			if ( isset( $users_args['role__in'] ) && ! empty( $users_args['role__in'] ) ) {
				$roles = (array) $users_args['role__in'];

				$select .= " INNER JOIN {$wpdb->usermeta} um ON u.ID = um.user_id WHERE um.meta_key LIKE %s AND (";
				// meta_key pattern (base prefix + %capabilities).
				$meta_key_pattern = $wpdb->base_prefix . '%capabilities';
				$params[]         = $meta_key_pattern;

				$role_like_parts = array();
				foreach ( $roles as $role ) {
					$role_escaped      = $wpdb->esc_like( (string) $role );
					$role_like_parts[] = 'um.meta_value LIKE %s';
					// pattern includes serialized quotes: %"role"%.
					$params[] = '%"' . $role_escaped . '"%';
				}

				$select .= implode( ' OR ', $role_like_parts );
				$select .= ' )';

				$excluded_users = ( ! empty( $users_args['excluded_users'] ) ) ? (array) $users_args['excluded_users'] : array();

				if ( ! empty( $excluded_users ) ) {
					$placeholders = implode( ',', array_fill( 0, count( $excluded_users ), '%s' ) );
					$select      .= " AND user_login NOT IN ( {$placeholders} )";
					foreach ( $excluded_users as $excluded_user ) {
						$params[] = (string) $excluded_user;
					}
				}

				$skip_existing_2fa_users = ( ! empty( $users_args['skip_existing_2fa_users'] ) ) ? (bool) $users_args['skip_existing_2fa_users'] : false;

				if ( $skip_existing_2fa_users ) {
					$select  .= " AND u.ID NOT IN ( SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s )";
					$params[] = 'wp_2fa_enabled_methods';
				}
			}

			if ( $batch_size ) {
				// append LIMIT/OFFSET using integer placeholders.
				$select  .= ' LIMIT %d OFFSET %d';
				$params[] = $batch_size;
				$params[] = $offset;
			}

			// If we have parameters, prepare the statement; otherwise run directly.
			if ( ! empty( $params ) ) {
				// Use splat operator to pass params array to prepare.
				$sql = $wpdb->prepare( $select, ...$params );
				return $wpdb->get_results( $sql );
			}

			return $wpdb->get_results( $select );
		}

		/**
		 * Retrieve string of comma separated IDs.
		 *
		 * @param string $method     Method to use.
		 * @param array  $users_args Query arguments.
		 *
		 * @return string List of IDs.
		 *
		 * @since 2.2.0
		 */
		public static function get_all_user_ids( $method, $users_args ) {
			$user_data = self::get_all_users_data( $method, $users_args );

			$users = array_map(
				function ( $user ) {
					return (int) $user->ID;
				},
				$user_data
			);

			// Sanitize the IDs before returning them as a comma-separated string.
			$sanitized_ids = array_map( 'intval', $users );

			return implode( ',', $sanitized_ids );
		}

		/**
		 * Retrieve array if user IDs and login names.
		 *
		 * @param string $method     Method to use.
		 * @param array  $users_args Query arguments.
		 *
		 * @return array User details.
		 *
		 * @since 2.2.0
		 */
		public static function get_all_user_ids_and_login_names( $method, $users_args ) {
			$user_data = self::get_all_users_data( $method, $users_args );

			$users = array_map(
				function ( $user ) {
					$user_item['ID']         = intval( $user->ID );
					$user_item['user_login'] = sanitize_user( $user->user_login, true );

					return $user_item;
				},
				$user_data
			);

			return $users;
		}

		/**
		 * Returns the array with human readable statuses of the WP 2FA.
		 *
		 * @since 1.6
		 *
		 * @return array
		 */
		public static function get_human_readable_user_statuses() {
			if ( null === self::$statuses ) {
				self::$statuses = array(
					'has_enabled_methods'                 => esc_html__( 'Configured', 'wp-2fa' ),
					'user_needs_to_setup_2fa'             => esc_html__( 'Required but not configured', 'wp-2fa' ),
					'no_required_has_enabled'             => esc_html__( 'Configured (but not required)', 'wp-2fa' ),
					'no_required_not_enabled'             => esc_html__( 'Not required & not configured', 'wp-2fa' ),
					'user_is_excluded'                    => esc_html__( 'Not allowed', 'wp-2fa' ),
					'user_is_locked'                      => esc_html__( 'Locked', 'wp-2fa' ),
					User_Helper::USER_UNDETERMINED_STATUS => esc_html__( 'User has not logged in yet, 2FA status is unknown', 'wp-2fa' ),
				);
			}

			return self::$statuses;
		}

		/**
		 * Gets the user types extracted with @see User_Utils::determine_user_2fa_status,
		 * checks values and generates human readable 2FA status text.
		 *
		 * @param array $user_types - The types of the user.
		 *
		 * @return array An array with the id and label elements of user 2FA status. Empty in case there is not match.
		 *
		 * @since 1.7.0 Changed the function to return the id and label of the first match it finds instead of concatenated labels of all matched statuses.
		 */
		public static function extract_statuses( array $user_types ) {
			if ( null === self::$statuses ) {
				self::get_human_readable_user_statuses();
			}

			if ( empty( $user_types ) ) {
				return array();
			}

			$key_to_search = sanitize_key( reset( $user_types ) );

			if ( isset( self::$statuses[ $key_to_search ] ) ) {
				return array(
					'id'    => $key_to_search,
					'label' => esc_html( self::$statuses[ $key_to_search ] ),
				);
			}

			return array();
		}
	}
}
