<?php
/**
 * Passwordless sign-in by phone.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Otp;

use TextTango\Api\PhoneNumber;
use TextTango\Messages\MessageLog;
use TextTango\Sending\Sender;
use TextTango\Support\Settings;
use WP_Error;
use WP_User;

defined( 'ABSPATH' ) || exit;

/**
 * Drives the two-step passwordless flow (spec §5).
 *
 * The controlling rule is that **nothing distinguishes a known number from an
 * unknown one**. Requesting a code returns one fixed response whatever the
 * outcome — user found, user absent, registration disabled, code sent, code
 * suppressed — so the form cannot be used to discover who holds an account.
 * Failure detail is reported only at the verify step, where the caller has
 * already proven possession of the handset.
 */
final class LoginController {

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

	/**
	 * Nonce action guarding both steps.
	 */
	public const NONCE = 'texttango_otp';

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'wp_ajax_nopriv_texttango_otp_request', array( $this, 'ajax_request_code' ) );
		add_action( 'wp_ajax_texttango_otp_request', array( $this, 'ajax_request_code' ) );
		add_action( 'wp_ajax_nopriv_texttango_otp_verify', array( $this, 'ajax_verify_code' ) );
		add_action( 'wp_ajax_texttango_otp_verify', array( $this, 'ajax_verify_code' ) );
	}

	/**
	 * Whether passwordless sign-in is switched on.
	 */
	public static function is_enabled(): bool {
		return (bool) Settings::get( 'otp_login_enabled', true );
	}

	/**
	 * Step one: send a code.
	 */
	public function ajax_request_code(): void {
		$this->guard();

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in guard().
		$raw   = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : '';
		$phone = PhoneNumber::normalize( $raw );

		// A malformed number is a formatting problem, not an account disclosure,
		// so it is safe — and much kinder — to say so plainly.
		if ( is_wp_error( $phone ) ) {
			wp_send_json_error( array( 'message' => $phone->get_error_message() ) );
		}

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

		if ( is_wp_error( $allowed ) ) {
			wp_send_json_error(
				array(
					'message'    => $allowed->get_error_message(),
					'retryAfter' => (int) ( $allowed->get_error_data()['retry_after'] ?? 0 ),
				)
			);
		}

		$result = self::request_code( $phone );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( array( 'message' => $result->get_error_message() ) );
		}

		wp_send_json_success(
			array(
				'message' => self::sent_message(),
				'phone'   => $phone,
				'expires' => OtpService::ttl(),
			)
		);
	}

	/**
	 * Issue and deliver a code, or deliberately do neither.
	 *
	 * @param string $phone Number in E.164.
	 * @return true|WP_Error True whenever the caller should be told "code sent",
	 *                       which includes the case where nothing was sent.
	 */
	public static function request_code( string $phone ) {
		$user = UserRepository::find_by_phone( $phone );

		/*
		 * No account, and this site does not create them. Charging the merchant to
		 * text a stranger would achieve nothing, so skip the send — but still burn
		 * the rate-limit allowance and return the same message, so the two cases
		 * stay indistinguishable from outside.
		 */
		if ( ! $user instanceof WP_User && ! UserRepository::registration_enabled() ) {
			Throttle::record( $phone );

			return true;
		}

		$code = OtpService::issue( $phone, self::PURPOSE );

		$sent = Sender::send(
			array(
				'to'         => $phone,
				'message'    => self::compose_message( $code ),
				'log_body'   => self::compose_message( str_repeat( '•', OtpService::CODE_LENGTH ) ),
				'event'      => 'otp',
				'dedupe_key' => MessageLog::unique_dedupe_key( 'otp' ),
				'wp_user_id' => $user instanceof WP_User ? $user->ID : null,
			)
		);

		if ( is_wp_error( $sent ) ) {
			// Leaving a challenge behind for a code nobody received would only
			// consume the user's attempts when they retried.
			OtpService::forget( $phone, self::PURPOSE );

			return new WP_Error(
				'texttango_otp_send_failed',
				__( 'We could not send your code right now. Please try again shortly.', 'texttango' )
			);
		}

		Throttle::record( $phone );

		return true;
	}

	/**
	 * Step two: check the code and sign the user in.
	 */
	public function ajax_verify_code(): void {
		$this->guard();

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in guard().
		$raw      = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : '';
		$code     = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
		$redirect = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : '';
		$remember = ! empty( $_POST['remember'] );
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		$phone = PhoneNumber::normalize( $raw );

		if ( is_wp_error( $phone ) ) {
			wp_send_json_error( array( 'message' => $phone->get_error_message() ) );
		}

		$verified = OtpService::verify( $phone, $code, self::PURPOSE );

		if ( is_wp_error( $verified ) ) {
			wp_send_json_error(
				array(
					'message' => $verified->get_error_message(),
					'code'    => $verified->get_error_code(),
				)
			);
		}

		$user = self::resolve_user( $phone );

		if ( is_wp_error( $user ) ) {
			wp_send_json_error( array( 'message' => $user->get_error_message() ) );
		}

		Throttle::clear( $phone );
		self::sign_in( $user, $remember );

		wp_send_json_success(
			array(
				'message'  => __( 'Signed in. Redirecting…', 'texttango' ),
				'redirect' => self::safe_redirect_target( $redirect, $user ),
			)
		);
	}

	/**
	 * The account behind a freshly verified number, creating one if allowed.
	 *
	 * @param string $phone Verified number in E.164.
	 * @return WP_User|WP_Error
	 */
	public static function resolve_user( string $phone ) {
		$user = UserRepository::find_by_phone( $phone );

		if ( $user instanceof WP_User ) {
			// Refresh the proof-of-possession timestamp on every successful sign-in.
			UserRepository::set_phone( $user->ID, $phone );

			return $user;
		}

		if ( ! UserRepository::registration_enabled() ) {
			return new WP_Error(
				'texttango_otp_no_account',
				__( 'We could not sign you in with that number.', 'texttango' )
			);
		}

		return UserRepository::create_from_phone( $phone );
	}

	/**
	 * Establish the session.
	 *
	 * @param WP_User $user     User to sign in.
	 * @param bool    $remember Whether to issue a long-lived cookie.
	 */
	public static function sign_in( WP_User $user, bool $remember = false ): void {
		wp_set_current_user( $user->ID );
		wp_set_auth_cookie( $user->ID, $remember );

		/**
		 * Fires when a user signs in by phone rather than by password.
		 *
		 * Must be fired before `wp_login`, because that is what tells
		 * {@see TwoFactor} not to demand a second SMS code — the code just
		 * entered already proved possession of the handset, and asking again
		 * would be the same factor twice.
		 *
		 * @param WP_User $user User who signed in.
		 */
		do_action( 'texttango_passwordless_login', $user );

		/*
		 * Core's own `wp_signon()` fires this, and everything that reacts to a
		 * login — session handling, security plugins, our own two-factor check —
		 * listens for it. Signing in without firing it would make a passwordless
		 * login invisible to the rest of the site.
		 */
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook, fired deliberately in place of wp_signon().
		do_action( 'wp_login', $user->user_login, $user );
	}

	/**
	 * Where to send the user next, refusing to leave the site.
	 *
	 * @param string  $requested Requested destination.
	 * @param WP_User $user      User signing in.
	 */
	public static function safe_redirect_target( string $requested, WP_User $user ): string {
		$fallback = user_can( $user, 'read' ) ? admin_url() : home_url( '/' );
		$target   = '' !== $requested ? $requested : $fallback;

		// Core's filter, so a site's existing redirect rules apply to this login
		// exactly as they do to a password one.
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter, applied deliberately for parity with wp_login_form().
		$target = apply_filters( 'login_redirect', $target, $requested, $user );

		return wp_validate_redirect( $target, $fallback );
	}

	/**
	 * The response every code request produces, whatever actually happened.
	 */
	public static function sent_message(): string {
		return __( 'If that number can sign in here, a code is on its way. Enter it below.', 'texttango' );
	}

	/**
	 * Body of the code message.
	 *
	 * @param string $code Plaintext code.
	 */
	private static function compose_message( string $code ): string {
		$template = (string) Settings::get( 'otp_template', '' );

		if ( '' === trim( $template ) ) {
			$template = __( '{otp_code} is your {store_name} verification code. It expires in {minutes} minutes.', 'texttango' );
		}

		return strtr(
			$template,
			array(
				'{otp_code}'   => $code,
				'{store_name}' => wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ),
				'{minutes}'    => (string) max( 1, (int) round( OtpService::ttl() / MINUTE_IN_SECONDS ) ),
				'{site_url}'   => home_url( '/' ),
			)
		);
	}

	/**
	 * Reject the request unless the feature is on and the nonce is good.
	 */
	private function guard(): void {
		if ( ! self::is_enabled() ) {
			wp_send_json_error(
				array( 'message' => __( 'Signing in by phone is not available.', 'texttango' ) ),
				404
			);
		}

		if ( ! check_ajax_referer( self::NONCE, 'nonce', false ) ) {
			wp_send_json_error(
				array( 'message' => __( 'Your session expired. Reload the page and try again.', 'texttango' ) ),
				403
			);
		}
	}
}
