<?php
/**
 * Mapping between phone numbers and WordPress users.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Otp;

use TextTango\Support\Settings;
use WP_Error;
use WP_User;

defined( 'ABSPATH' ) || exit;

/**
 * Finds and creates the users behind a phone number (spec §5, §10).
 */
final class UserRepository {

	/**
	 * Usermeta holding the verified number in E.164.
	 */
	public const META_PHONE = 'texttango_phone';

	/**
	 * Usermeta recording when the number was last proven.
	 */
	public const META_VERIFIED_AT = 'texttango_phone_verified_at';

	/**
	 * Usermeta opting a user into SMS two-factor authentication.
	 */
	public const META_2FA = 'texttango_2fa_enabled';

	/**
	 * The user owning a phone number.
	 *
	 * @param string $phone Number in E.164.
	 */
	public static function find_by_phone( string $phone ): ?WP_User {
		if ( '' === $phone ) {
			return null;
		}

		$users = get_users(
			array(
				'meta_key'   => self::META_PHONE, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Looking a user up by phone is the entire point of this flow.
				'meta_value' => $phone,           // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- As above.
				'number'     => 1,
				'fields'     => 'all',
			)
		);

		return ! empty( $users ) && $users[0] instanceof WP_User ? $users[0] : null;
	}

	/**
	 * The number attached to a user.
	 *
	 * @param int $user_id User id.
	 */
	public static function phone_for( int $user_id ): string {
		return (string) get_user_meta( $user_id, self::META_PHONE, true );
	}

	/**
	 * Which account, if any, already holds a number.
	 *
	 * A number must map to exactly one account. {@see self::find_by_phone()}
	 * returns whichever row comes first, so two accounts sharing a number would
	 * make sign-in ambiguous — and would let whoever claimed it second receive
	 * the other person's codes.
	 *
	 * @param string $phone Number in E.164.
	 * @return int|null Owning user id, or null when the number is free.
	 */
	public static function phone_owner( string $phone ): ?int {
		$user = self::find_by_phone( $phone );

		return $user instanceof WP_User ? $user->ID : null;
	}

	/**
	 * Attach a verified number to a user.
	 *
	 * @param int    $user_id User id.
	 * @param string $phone   Number in E.164.
	 */
	public static function set_phone( int $user_id, string $phone ): void {
		update_user_meta( $user_id, self::META_PHONE, $phone );
		update_user_meta( $user_id, self::META_VERIFIED_AT, gmdate( 'Y-m-d H:i:s' ) );
	}

	/**
	 * Whether a user has opted into SMS two-factor authentication.
	 *
	 * @param WP_User $user User to check.
	 */
	public static function wants_two_factor( WP_User $user ): bool {
		if ( '' === self::phone_for( $user->ID ) ) {
			// Enforcing a second factor we cannot deliver would lock the user out.
			return false;
		}

		$enabled = '1' === (string) get_user_meta( $user->ID, self::META_2FA, true );

		if ( ! $enabled ) {
			$enforced = (array) Settings::get( 'otp_2fa_roles', array() );
			$enabled  = (bool) array_intersect( $enforced, (array) $user->roles );
		}

		/**
		 * Filters whether a user must pass SMS two-factor authentication.
		 *
		 * @param bool    $enabled Whether a second factor is required.
		 * @param WP_User $user    User signing in.
		 */
		return (bool) apply_filters( 'texttango_requires_two_factor', $enabled, $user );
	}

	/**
	 * Whether an unknown number may create an account.
	 */
	public static function registration_enabled(): bool {
		return (bool) Settings::get( 'otp_allow_registration', true );
	}

	/**
	 * Create an account for a freshly verified number.
	 *
	 * The username is random rather than derived from the number: WordPress
	 * exposes usernames in author archives and REST responses, and a username
	 * built from a phone number would publish it.
	 *
	 * @param string $phone Verified number in E.164.
	 * @return WP_User|WP_Error
	 */
	public static function create_from_phone( string $phone ) {
		$user_id = wp_insert_user(
			array(
				'user_login' => self::unique_username(),
				'user_pass'  => wp_generate_password( 32, true, true ),
				'role'       => self::default_role(),
				'nickname'   => __( 'Member', 'texttango' ),
			)
		);

		if ( is_wp_error( $user_id ) ) {
			return $user_id;
		}

		self::set_phone( (int) $user_id, $phone );

		$user = get_user_by( 'id', (int) $user_id );

		if ( ! $user instanceof WP_User ) {
			return new WP_Error(
				'texttango_registration_failed',
				__( 'The account could not be created.', 'texttango' )
			);
		}

		/**
		 * Fires after an account is created from a verified phone number.
		 *
		 * @param WP_User $user  Newly created user.
		 * @param string  $phone Verified number in E.164.
		 */
		do_action( 'texttango_user_registered_by_phone', $user, $phone );

		return $user;
	}

	/**
	 * Role assigned to accounts created by phone.
	 */
	private static function default_role(): string {
		$role = (string) Settings::get( 'otp_registration_role', '' );

		if ( '' === $role || ! self::is_safe_self_registration_role( $role ) ) {
			// The site's own default might not be safe either — a site that
			// changed it to something privileged should not leak that here.
			$role = (string) get_option( 'default_role', 'subscriber' );
		}

		return self::is_safe_self_registration_role( $role ) ? $role : 'subscriber';
	}

	/**
	 * Whether a role is safe to hand to an unauthenticated caller.
	 *
	 * Self-registration is reachable by anyone who can receive an SMS, so the
	 * result must never exceed what a brand new WordPress account already
	 * gets: read access and nothing else. A role that also carries, say,
	 * edit_posts or manage_woocommerce would let an unauthenticated caller
	 * opt itself into capabilities a subscriber never has — which is exactly
	 * what `administrator` was already excluded for, generalised to cover
	 * every other privileged role too (Editor, Author, Shop Manager, or any
	 * custom role like them).
	 *
	 * @param string $role Role slug.
	 */
	public static function is_safe_self_registration_role( string $role ): bool {
		$details = get_role( $role );

		if ( ! $details instanceof \WP_Role ) {
			return false;
		}

		$capabilities = array_keys( array_filter( $details->capabilities ) );

		return array() === array_diff( $capabilities, array( 'read' ) );
	}

	/**
	 * A username no existing account is using.
	 */
	private static function unique_username(): string {
		do {
			$candidate = 'user_' . strtolower( wp_generate_password( 10, false, false ) );
		} while ( username_exists( $candidate ) );

		return $candidate;
	}
}
