<?php
/**
 * Attaching a verified phone number to an existing account.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Otp;

use TextTango\Api\PhoneNumber;
use TextTango\Contacts\ContactSync;
use TextTango\Messages\MessageLog;
use TextTango\Sending\Sender;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Lets a signed-in user prove they hold a number, and attaches it (spec §5).
 *
 * Without this, only accounts *created* by signing in with a phone ever have
 * one — so every account that existed before the plugin was installed could
 * never use two-factor, and had no way to link its own number.
 *
 * A number is attached only by verifying a code sent to it. Three rules keep
 * that meaningful:
 *
 * 1. **The target is always the current user.** No user id is read from the
 *    request, so there is no parameter to tamper with, and not even an
 *    administrator can attach a number to somebody else's account — the person
 *    holding the handset is the only one who can.
 * 2. **The purpose is `attach`, never `login`.** `OtpService` binds the purpose
 *    into the challenge key, so a code sent for signing in cannot be replayed
 *    here, and vice versa.
 * 3. **One number, one account.** A number already held by another user is
 *    refused, because sign-in resolves a user *from* the number.
 */
final class PhoneVerification {

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

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

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'wp_ajax_texttango_attach_phone_request', array( $this, 'ajax_request_code' ) );
		add_action( 'wp_ajax_texttango_attach_phone_verify', array( $this, 'ajax_verify_code' ) );
	}

	/**
	 * Step one: send a code to the number being claimed.
	 */
	public function ajax_request_code(): void {
		$user_id = $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 );

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

		$conflict = self::attachment_error( UserRepository::phone_owner( $phone ), $user_id );

		if ( null !== $conflict ) {
			wp_send_json_error( array( 'message' => $conflict ) );
		}

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

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

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

		$sent = Sender::send(
			array(
				'to'             => $phone,
				'message'        => self::compose_message( $code ),
				// The log keeps a redacted copy; the real code exists only in the SMS.
				'log_body'       => self::compose_message( str_repeat( '•', OtpService::CODE_LENGTH ) ),
				'event'          => 'phone_verification',
				'dedupe_key'     => MessageLog::unique_dedupe_key( 'phone_verification' ),
				'wp_user_id'     => $user_id,
				/*
				 * Deliberately real, even in sandbox. The point of the code is to
				 * prove someone is holding that handset, and a logged-but-unsent code
				 * proves nothing — its digits are redacted in the log precisely so
				 * they cannot be read back.
				 */
				'bypass_sandbox' => true,
			)
		);

		if ( is_wp_error( $sent ) ) {
			OtpService::forget( $phone, self::PURPOSE );

			wp_send_json_error( array( 'message' => $sent->get_error_message() ) );
		}

		Throttle::record( $phone );

		wp_send_json_success(
			array(
				'message' => sprintf(
					/* translators: %s: masked phone number. */
					__( 'Code sent to %s. Enter it below to finish.', 'texttango' ),
					PhoneNumber::mask( $phone )
				),
				'phone'   => $phone,
			)
		);
	}

	/**
	 * Step two: check the code and attach the number.
	 */
	public function ajax_verify_code(): void {
		$user_id = $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'] ) ) : '';
		// 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() ) );
		}

		// Re-checked here: someone else could have claimed the number between the
		// code being sent and this request arriving.
		$conflict = self::attachment_error( UserRepository::phone_owner( $phone ), $user_id );

		if ( null !== $conflict ) {
			wp_send_json_error( array( 'message' => $conflict ) );
		}

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

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

		UserRepository::set_phone( $user_id, $phone );
		Throttle::clear( $phone );

		/**
		 * Fires after a user attaches a verified number to their own account.
		 *
		 * @param int    $user_id User the number now belongs to.
		 * @param string $phone   Verified number in E.164.
		 */
		do_action( 'texttango_phone_attached', $user_id, $phone );

		// A newly attached number may be the first one this customer has, so the
		// contact they consented to becomes syncable only now.
		ContactSync::enqueue( 'user', $user_id );

		wp_send_json_success(
			array(
				'message' => sprintf(
					/* translators: %s: masked phone number. */
					__( '%s is now your sign-in number.', 'texttango' ),
					PhoneNumber::mask( $phone )
				),
				'masked'  => PhoneNumber::mask( $phone ),
			)
		);
	}

	/**
	 * Why a number cannot be attached, or null when it can.
	 *
	 * Pure, so the rule can be reasoned about without a database.
	 *
	 * @param int|null $owner_id        Account that already holds the number, if any.
	 * @param int      $current_user_id Account asking for it.
	 */
	public static function attachment_error( ?int $owner_id, int $current_user_id ): ?string {
		if ( null === $owner_id || $owner_id === $current_user_id ) {
			return null;
		}

		/*
		 * Deliberately vague about who holds it. Naming the account would turn this
		 * form into a way of asking "which of these numbers belongs to a user here".
		 */
		return __( 'That number is already in use on this site.', 'texttango' );
	}

	/**
	 * Body of the verification 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 phone verification code.', 'texttango' ),
			$code,
			wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES )
		);
	}

	/**
	 * Reject the request unless it is nonced and from a signed-in user.
	 *
	 * Returns the id this flow will act on. Any account may manage its own
	 * number, so this deliberately does not require `manage_texttango` — that
	 * capability is for site settings, not for a subscriber's own profile.
	 */
	private function guard(): int {
		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
			);
		}

		$user_id = get_current_user_id();

		if ( $user_id <= 0 ) {
			wp_send_json_error(
				array( 'message' => __( 'You need to be signed in to do that.', 'texttango' ) ),
				403
			);
		}

		return $user_id;
	}
}
