<?php
/**
 * SMS second factor after a password sign-in.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Otp;

use TextTango\Messages\MessageLog;
use TextTango\Sending\Sender;
use TextTango\Support\Settings;
use WP_User;

defined( 'ABSPATH' ) || exit;

/**
 * Interrupts a completed password login to demand an SMS code (spec §5).
 *
 * WordPress has no hook that fires between "credentials accepted" and "session
 * established", so the sequence used here is the one the core Two Factor plugin
 * settled on: let `wp_signon()` finish, then on `wp_login` immediately clear the
 * cookie it just set, render an interstitial, and stop. The session only becomes
 * real once the code is verified. Because the cookie is cleared before any output,
 * a user who closes the tab at the interstitial is not signed in.
 */
final class TwoFactor {

	/**
	 * Purpose recorded on challenges issued by this flow.
	 */
	public const PURPOSE = '2fa';

	/**
	 * `wp-login.php?action=` value that handles the second step.
	 */
	public const ACTION = 'texttango_2fa';

	/**
	 * Nonce action for the interstitial form.
	 */
	private const NONCE = 'texttango_2fa_login';

	/**
	 * How long a pending login may sit unfinished, in seconds.
	 */
	private const PENDING_TTL = 600;

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'wp_login', array( $this, 'maybe_interrupt' ), 10, 2 );
		add_action( 'login_form_' . self::ACTION, array( $this, 'handle_second_step' ) );
	}

	/**
	 * Whether the second factor is switched on site-wide.
	 */
	public static function is_enabled(): bool {
		return (bool) Settings::get( 'otp_2fa_enabled', false );
	}

	/**
	 * Hold a freshly authenticated login until a code is entered.
	 *
	 * @param string       $user_login Username.
	 * @param WP_User|null $user       Authenticated user.
	 */
	public function maybe_interrupt( $user_login, $user = null ): void {
		if ( ! self::is_enabled() || ! $user instanceof WP_User ) {
			return;
		}

		// The passwordless flow already proved possession of the handset; asking
		// for a second SMS code would be the same factor twice.
		if ( did_action( 'texttango_passwordless_login' ) > 0 ) {
			return;
		}

		if ( ! UserRepository::wants_two_factor( $user ) ) {
			return;
		}

		$phone = UserRepository::phone_for( $user->ID );

		wp_clear_auth_cookie();

		$token = self::start_pending_login( $user );

		$allowed = Throttle::check( $phone );

		if ( ! is_wp_error( $allowed ) ) {
			$code = OtpService::issue( $phone, self::PURPOSE );

			$sent = Sender::send(
				array(
					'to'         => $phone,
					'message'    => self::compose_message( $code ),
					// Never store a live code — see Sending\Sender.
					'log_body'   => self::compose_message( str_repeat( '•', OtpService::CODE_LENGTH ) ),
					'event'      => 'otp_2fa',
					'dedupe_key' => MessageLog::unique_dedupe_key( 'otp_2fa' ),
					'wp_user_id' => $user->ID,
				)
			);

			if ( ! is_wp_error( $sent ) ) {
				Throttle::record( $phone );
			}
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the destination core already parsed; validated before use.
		$redirect = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : '';

		self::render_interstitial( $token, $redirect, '' );
		exit;
	}

	/**
	 * Verify the code and finish, or re-render with an error.
	 */
	public function handle_second_step(): void {
		if ( 'POST' !== ( isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '' ) ) {
			wp_safe_redirect( wp_login_url() );
			exit;
		}

		check_admin_referer( self::NONCE );

		$token    = isset( $_POST['texttango_token'] ) ? sanitize_text_field( wp_unslash( $_POST['texttango_token'] ) ) : '';
		$code     = isset( $_POST['texttango_code'] ) ? sanitize_text_field( wp_unslash( $_POST['texttango_code'] ) ) : '';
		$redirect = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : '';

		$user = self::resolve_pending_login( $token );

		if ( ! $user instanceof WP_User ) {
			wp_safe_redirect( add_query_arg( 'texttango_expired', '1', wp_login_url() ) );
			exit;
		}

		$phone    = UserRepository::phone_for( $user->ID );
		$verified = OtpService::verify( $phone, $code, self::PURPOSE );

		if ( is_wp_error( $verified ) ) {
			if ( 'texttango_otp_invalid' !== $verified->get_error_code() ) {
				// Expired or locked out: the pending login is spent too.
				self::end_pending_login( $token );

				wp_safe_redirect( add_query_arg( 'texttango_expired', '1', wp_login_url() ) );
				exit;
			}

			self::render_interstitial( $token, $redirect, $verified->get_error_message() );
			exit;
		}

		self::end_pending_login( $token );
		Throttle::clear( $phone );

		wp_set_current_user( $user->ID );
		wp_set_auth_cookie( $user->ID, self::pending_remember( $token ) );

		wp_safe_redirect( LoginController::safe_redirect_target( $redirect, $user ) );
		exit;
	}

	/**
	 * Park a half-finished login and return its opaque token.
	 *
	 * @param WP_User $user Authenticated user.
	 */
	private static function start_pending_login( WP_User $user ): string {
		$token = wp_generate_password( 40, false, false );

		set_transient(
			self::pending_key( $token ),
			array(
				'user_id'  => $user->ID,
				// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Boolean UI preference only; core already validated the login request.
				'remember' => ! empty( $_REQUEST['rememberme'] ),
			),
			self::PENDING_TTL
		);

		return $token;
	}

	/**
	 * The user behind a pending-login token.
	 *
	 * @param string $token Token from the interstitial form.
	 */
	private static function resolve_pending_login( string $token ): ?WP_User {
		if ( '' === $token ) {
			return null;
		}

		$pending = get_transient( self::pending_key( $token ) );

		if ( ! is_array( $pending ) || empty( $pending['user_id'] ) ) {
			return null;
		}

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

		return $user instanceof WP_User ? $user : null;
	}

	/**
	 * Whether a pending login asked to be remembered.
	 *
	 * @param string $token Token from the interstitial form.
	 */
	private static function pending_remember( string $token ): bool {
		$pending = get_transient( self::pending_key( $token ) );

		return is_array( $pending ) && ! empty( $pending['remember'] );
	}

	/**
	 * Discard a pending login.
	 *
	 * @param string $token Token from the interstitial form.
	 */
	private static function end_pending_login( string $token ): void {
		delete_transient( self::pending_key( $token ) );
	}

	/**
	 * Transient key for a pending login.
	 *
	 * @param string $token Opaque token.
	 */
	private static function pending_key( string $token ): string {
		return 'texttango_2fa_' . hash_hmac( 'sha256', $token, wp_salt() );
	}

	/**
	 * Render the code form in place of the login screen.
	 *
	 * @param string $token    Pending-login token.
	 * @param string $redirect Requested destination.
	 * @param string $error    Message to show, if any.
	 */
	private static function render_interstitial( string $token, string $redirect, string $error ): void {
		login_header(
			__( 'Two-factor authentication', 'texttango' ),
			'' !== $error ? '<div id="login_error">' . esc_html( $error ) . '</div>' : ''
		);

		?>
		<form name="texttango_2fa" id="texttango_2fa" method="post"
			action="<?php echo esc_url( site_url( 'wp-login.php?action=' . self::ACTION, 'login_post' ) ); ?>">
			<input type="hidden" name="texttango_token" value="<?php echo esc_attr( $token ); ?>" />
			<input type="hidden" name="redirect_to" value="<?php echo esc_attr( $redirect ); ?>" />
			<?php wp_nonce_field( self::NONCE ); ?>

			<p><?php esc_html_e( 'We sent a code to your phone. Enter it to finish signing in.', 'texttango' ); ?></p>

			<p>
				<label for="texttango_code"><?php esc_html_e( 'Verification code', 'texttango' ); ?></label>
				<input type="text" name="texttango_code" id="texttango_code" class="input"
					value="" size="20" autocomplete="one-time-code" inputmode="numeric"
					pattern="[0-9]*" autofocus="autofocus" />
			</p>

			<p class="submit">
				<input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large"
					value="<?php esc_attr_e( 'Verify', 'texttango' ); ?>" />
			</p>
		</form>
		<p id="backtoblog">
			<a href="<?php echo esc_url( wp_login_url() ); ?>"><?php esc_html_e( '← Back to sign in', 'texttango' ); ?></a>
		</p>
		<?php

		login_footer();
	}

	/**
	 * Body of the second-factor message.
	 *
	 * @param string $code Plaintext code.
	 */
	private static function compose_message( string $code ): string {
		return sprintf(
			/* translators: 1: the one-time code, 2: the site name. */
			__( '%1$s is your %2$s sign-in code.', 'texttango' ),
			$code,
			wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES )
		);
	}
}
