<?php
/**
 * Login ID Type Module
 *
 * Restricts how users authenticate (username only, email only, or both) and keeps
 * login behavior and UI synchronized. Uses a single unified instructional message
 * to avoid user-enumeration leaks. Does not affect registration or password reset.
 *
 * @package LoginIdType
 * @since 1.0.0
 */

namespace LoginIdType;

use WP_PowerSuite\Auth\CredentialErrorNormalizer;
use WP_PowerSuite\Auth\LoginRequestDetector;
use WP_PowerSuite\Core\AbstractModule;

/**
 * Login ID Type Module Class
 *
 * @since 1.0.0
 */
class Module extends AbstractModule {

	private const MODULE_ID = 'login-id-type';
	private const MODULE_VERSION = '1.3.0';

	/** @var string[] Allowed mode values. */
	private const MODES = [ 'username_email', 'username_only', 'email_only' ];

	/** @var array<string, mixed>|null */
	private ?array $settings_cache = null;

	private static bool $login_hooks_registered = false;

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'Login ID Type', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Control whether logins use username, email, or both—cleaner experience and fewer hints to guessers.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return [ 'frontend', 'ajax', 'rest' ];
	}

	protected function registerHooks(): void {
		if ( $this->getLoginIdMode() === 'username_email' ) {
			return;
		}

		if ( LoginRequestDetector::shouldRegisterAuthHooksImmediately() ) {
			$this->registerLoginHooks();
		}

		add_action( 'login_init', [ $this, 'registerLoginHooks' ], 0 );
		add_action( 'wp_loaded', [ $this, 'maybeRegisterLoginHooks' ], 0 );

		if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
			add_action( 'init', [ $this, 'registerLoginHooks' ], 0 );
		}
	}

	/**
	 * Register login-only hooks once per request (wp-login, custom login URL, credential POST).
	 */
	public function registerLoginHooks(): void {
		if ( self::$login_hooks_registered ) {
			return;
		}
		self::$login_hooks_registered = true;

		add_filter( 'authenticate', [ $this, 'enforceLoginIdType' ], 5, 3 );
		remove_filter( 'authenticate', 'wp_authenticate_username_password', 20 );
		remove_filter( 'authenticate', 'wp_authenticate_email_password', 20 );
		add_filter( 'authenticate', [ $this, 'wrapDefaultAuthenticator' ], 20, 3 );

		add_filter( 'login_form_defaults', [ $this, 'filterLoginFormDefaults' ], 10, 1 );
		add_filter( 'gettext', [ $this, 'filterLoginLabelGettext' ], 20, 3 );

		if ( $this->shouldOwnLoginErrorMessages() ) {
			add_filter( 'authenticate', [ $this, 'filterAuthenticateCredentialErrors' ], 99, 3 );
			add_filter( 'wp_login_errors', [ $this, 'filterWpLoginErrors' ], 50, 2 );
			add_filter( 'login_errors', [ $this, 'normalizeLoginErrors' ], 99998, 1 );

			if ( class_exists( 'WooCommerce', false ) ) {
				add_filter( 'woocommerce_process_login_errors', [ $this, 'filterWooCredentialErrors' ], 999, 3 );
			}
		}

		add_action( 'wp_ajax_nopriv_wpps_2fa_login', [ $this, 'registerAjaxLoginMessageFilter' ], 1 );
	}

	/**
	 * Generic Login Error Message owns credential copy when both modules are on.
	 */
	private function shouldOwnLoginErrorMessages(): bool {
		return ! \WP_PowerSuite\Core\OptionStoreModuleSettingsRepository::isModuleEnabled( 'custom-login-error-messages' );
	}

	public function registerAjaxLoginMessageFilter(): void {
		if ( ! $this->shouldOwnLoginErrorMessages() ) {
			return;
		}

		static $registered = false;
		if ( $registered ) {
			return;
		}
		$registered = true;

		add_filter( 'wp_powersuite_ajax_login_generic_message', [ $this, 'filterAjaxLoginMessage' ], 10, 3 );
	}

	/**
	 * @param string         $default Default AJAX message.
	 * @param \WP_Error|null $error   Authentication error.
	 * @param string         $context Source module identifier.
	 */
	public function filterAjaxLoginMessage( $default, $error = null, $context = '' ) {
		unset( $context );

		if ( $error instanceof \WP_Error && CredentialErrorNormalizer::errorIsCredentialFailure( $error, true ) ) {
			return $this->getInstructionalMessage();
		}

		return $default;
	}

	/**
	 * @param \WP_User|\WP_Error|null $user     User, error, or null.
	 * @param mixed                  $username Login identifier.
	 * @param mixed                  $password Password.
	 * @return \WP_User|\WP_Error|null
	 */
	public function filterAuthenticateCredentialErrors( $user, $username, $password ) {
		unset( $username, $password );

		if ( ! $user instanceof \WP_Error || ! CredentialErrorNormalizer::errorIsCredentialFailure( $user, true ) ) {
			return $user;
		}

		return $this->replaceCredentialFailureError( $user, false );
	}

	/**
	 * @param \WP_Error $errors     Login errors object.
	 * @param string    $redirect_to Redirect URL.
	 */
	public function filterWpLoginErrors( \WP_Error $errors, $redirect_to ): \WP_Error {
		unset( $redirect_to );

		if ( ! LoginRequestDetector::isMainLoginForm() || ! $errors->has_errors() ) {
			return $errors;
		}

		return $this->replaceCredentialFailureError( $errors, true );
	}

	/**
	 * @param \WP_Error $validation_error WooCommerce validation errors.
	 * @param string    $username         Submitted username.
	 * @param string    $password         Submitted password.
	 */
	public function filterWooCredentialErrors( $validation_error, $username, $password ) {
		unset( $username, $password );

		if ( ! $validation_error instanceof \WP_Error || ! $validation_error->has_errors() ) {
			return $validation_error;
		}

		if ( ! CredentialErrorNormalizer::errorIsCredentialFailure( $validation_error, true ) ) {
			return $validation_error;
		}

		return $this->replaceCredentialFailureError( $validation_error, false );
	}

	/**
	 * WooCommerce and other front-end login forms do not run login_init.
	 */
	public function maybeRegisterLoginHooks(): void {
		if ( self::$login_hooks_registered ) {
			return;
		}

		if ( LoginRequestDetector::shouldRegisterAuthHooksImmediately() ) {
			$this->registerLoginHooks();
		}
	}

	/**
	 * Login form labels on wp-login and WooCommerce My Account.
	 *
	 * @param string $translation Translated text.
	 * @param string $text        Source string (msgid).
	 * @param string $domain      Text domain.
	 */
	public function filterLoginLabelGettext( string $translation, string $text, string $domain ): string {
		if ( ! $this->isLoginLabelContext() ) {
			return $translation;
		}

		$label = $this->getLoginLabel();

		if ( $domain === 'default' && $text === 'Username or Email Address' && LoginRequestDetector::isMainLoginForm() ) {
			return $label;
		}

		if ( $domain === 'woocommerce' && $text === 'Username or email address' ) {
			return $label;
		}

		return $translation;
	}

	private function isLoginLabelContext(): bool {
		if ( function_exists( 'is_login' ) && is_login() ) {
			return true;
		}

		if ( apply_filters( 'wp_powersuite_is_custom_login_url_request', false ) ) {
			return true;
		}

		if ( function_exists( 'is_account_page' ) && is_account_page() ) {
			return true;
		}

		return LoginRequestDetector::isCredentialPostRequest();
	}

	private function getInstructionalMessage(): string {
		switch ( $this->getLoginIdMode() ) {
			case 'email_only':
				return __( 'Use your email address to log in.', 'wp-powersuite' );
			case 'username_only':
				return __( 'Use your username to log in.', 'wp-powersuite' );
			default:
				return __( 'Use your username or email address to log in.', 'wp-powersuite' );
		}
	}

	private function getFormattedInstructionalMessage(): string {
		return '<p>' . esc_html( $this->getInstructionalMessage() ) . '</p>';
	}

	/**
	 * @param \WP_Error $errors   Errors to normalize.
	 * @param bool      $use_html Whether the message is rendered as HTML on wp-login.
	 */
	private function replaceCredentialFailureError( \WP_Error $errors, bool $use_html ): \WP_Error {
		if ( ! CredentialErrorNormalizer::stripCredentialErrorCodes( $errors, true ) ) {
			return $errors;
		}

		$message = $use_html ? $this->getFormattedInstructionalMessage() : $this->getInstructionalMessage();
		$errors->add( CredentialErrorNormalizer::LOGIN_ID_TYPE_CODE, $message );

		return $errors;
	}

	private function getLoginLabel(): string {
		switch ( $this->getLoginIdMode() ) {
			case 'email_only':
				return __( 'Email address', 'wp-powersuite' );
			case 'username_only':
				return __( 'Username', 'wp-powersuite' );
			default:
				return __( 'Username or Email Address', 'wp-powersuite' );
		}
	}

	/**
	 * Fallback when rendered HTML still exposes core credential wording.
	 */
	public function normalizeLoginErrors( string $errors ): string {
		if ( ! LoginRequestDetector::isMainLoginForm() || $errors === '' ) {
			return $errors;
		}

		if ( str_contains( $errors, 'wpps-lockout-message' ) ) {
			return $errors;
		}

		if ( ! CredentialErrorNormalizer::loginErrorHtmlLooksLikeCredentialLeak( $errors ) ) {
			return $errors;
		}

		return $this->getFormattedInstructionalMessage();
	}

	/**
	 * @param array<string, mixed> $defaults Default form arguments.
	 * @return array<string, mixed>
	 */
	public function filterLoginFormDefaults( array $defaults ): array {
		if ( isset( $defaults['label_username'] ) ) {
			$defaults['label_username'] = $this->getLoginLabel();
		}
		return $defaults;
	}

	/**
	 * @param \WP_User|\WP_Error|null $user     User or error from previous filter.
	 * @param mixed                  $username Login identifier.
	 * @param mixed                  $password Password.
	 * @return \WP_User|\WP_Error
	 */
	public function wrapDefaultAuthenticator( $user, $username, $password ) {
		if ( $user instanceof \WP_Error ) {
			return $user;
		}
		if ( $user instanceof \WP_User ) {
			return $user;
		}
		$u = (string) $username;
		$p = (string) $password;
		$user = wp_authenticate_username_password( $user, $u, $p );
		if ( $user instanceof \WP_User ) {
			return $user;
		}
		return wp_authenticate_email_password( $user, $u, $p );
	}

	/**
	 * @param \WP_User|\WP_Error|null $user     User or error from previous filter.
	 * @param mixed                  $username Login identifier.
	 * @param mixed                  $password Password (unused).
	 * @return \WP_User|\WP_Error|null
	 */
	public function enforceLoginIdType( $user, $username, $password ) {
		unset( $password );

		$login = is_string( $username ) ? trim( $username ) : '';
		if ( $login === '' ) {
			return $user;
		}

		$mode     = $this->getLoginIdMode();
		$is_email = is_email( $login );

		if ( $mode === 'username_only' && $is_email ) {
			return new \WP_Error(
				CredentialErrorNormalizer::LOGIN_ID_TYPE_CODE,
				$this->getInstructionalMessage()
			);
		}

		if ( $mode === 'email_only' && ! $is_email ) {
			return new \WP_Error(
				CredentialErrorNormalizer::LOGIN_ID_TYPE_CODE,
				$this->getInstructionalMessage()
			);
		}

		return $user;
	}

	private function getLoginIdMode(): string {
		$settings = $this->getSettings();
		$mode     = $settings['login_id_type'] ?? 'username_email';
		return in_array( $mode, self::MODES, true ) ? $mode : 'username_email';
	}

	/**
	 * @return array<string, mixed>
	 */
	private function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}

		$settings = $this->moduleSettings()->all( [] );
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}

		$mode = $settings['login_id_type'] ?? 'username_email';
		if ( $mode === 'both' ) {
			$mode = 'username_email';
		}

		$this->settings_cache = [
			'login_id_type' => in_array( $mode, self::MODES, true ) ? $mode : 'username_email',
		];

		return $this->settings_cache;
	}
}
