<?php
/**
 * An OTP step gating order placement.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Otp;

use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use TextTango\Api\PhoneNumber;
use TextTango\Messages\MessageLog;
use TextTango\Sending\Sender;
use TextTango\Support\Settings;
use WC_Order;
use WC_Session_Handler;
use WP_Error;
use WP_User;

defined( 'ABSPATH' ) || exit;

/**
 * Requires proof of a phone number before WooCommerce places an order (spec §16).
 *
 * Off until a merchant switches it on, like every other behaviour change this
 * plugin makes. When it is on, a customer sends themselves a code and types it
 * in; the enforcement itself never trusts that the browser did — both checkout
 * paths re-check server-side, at the point each actually creates the order, so
 * disabling JavaScript or posting to the Store API directly cannot skip it.
 *
 * ### Two checkout paths, two enforcement points
 *
 * Classic checkout validates on `woocommerce_after_checkout_validation`, which
 * runs against `$data['billing_phone']` already assembled by
 * `WC_Checkout::get_posted_data()`. Block checkout is a Store API request;
 * `woocommerce_store_api_checkout_update_order_from_request` fires after the
 * order's billing address has already been refreshed from the request, so
 * `$order->get_billing_phone()` is the number to check. A callback there that
 * throws {@see RouteException} aborts the request outright, which is the same
 * standing a malformed address or a declined payment already has.
 *
 * ### What "verified" means and where it lives
 *
 * A verified phone is remembered in WooCommerce's own session — cookie-backed,
 * guest-safe, the same store WooCommerce itself uses for the cart — keyed by a
 * single string rather than a set, so a customer who verifies one number and
 * then edits the phone field is checked against the number they verified, not
 * whatever they typed most recently. Cleared the moment an order is actually
 * created, on both paths, so a verification cannot be reused for a later,
 * unrelated checkout in the same session.
 */
final class CheckoutGate {

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

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

	/**
	 * Field id for the code-entry field on block checkout. Namespaced, as the
	 * Additional Checkout Fields API requires.
	 */
	public const FIELD_CODE = 'texttango/checkout_otp_code';

	/**
	 * Session key the verified phone is remembered under.
	 */
	private const SESSION_KEY = 'texttango_checkout_verified_phone';

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'wp_ajax_nopriv_texttango_checkout_otp_request', array( $this, 'ajax_request_code' ) );
		add_action( 'wp_ajax_texttango_checkout_otp_request', array( $this, 'ajax_request_code' ) );
		add_action( 'wp_ajax_nopriv_texttango_checkout_otp_verify', array( $this, 'ajax_verify_code' ) );
		add_action( 'wp_ajax_texttango_checkout_otp_verify', array( $this, 'ajax_verify_code' ) );

		add_action( 'woocommerce_init', array( $this, 'register_field' ) );
		add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue_assets' ) );
		add_action( 'woocommerce_after_checkout_billing_form', array( $this, 'render_classic_widget' ) );

		add_action( 'woocommerce_after_checkout_validation', array( $this, 'validate_classic' ), 10, 2 );
		// Only 1 of the 2 args this hook fires with: $order is all validate_blocks()
		// needs, and by the time this fires it already carries the billing address
		// the request submitted.
		add_action( 'woocommerce_store_api_checkout_update_order_from_request', array( $this, 'validate_blocks' ), 10, 1 );

		// A verification must not outlive the checkout it belonged to, so it
		// cannot be replayed against a different order placed later in the same
		// session.
		add_action( 'woocommerce_checkout_order_created', array( $this, 'clear_verified_session' ) );
		add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'clear_verified_session' ) );
	}

	/**
	 * Whether the gate is switched on.
	 */
	public static function is_enabled(): bool {
		return (bool) Settings::get( 'checkout_otp_enabled', false );
	}

	/**
	 * Step one: send a code to the number being checked out with.
	 */
	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 );

		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 ),
				)
			);
		}

		$code = OtpService::issue( $phone, self::PURPOSE );
		$user = is_user_logged_in() ? wp_get_current_user() : null;

		$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'          => 'checkout_otp',
				'dedupe_key'     => MessageLog::unique_dedupe_key( 'checkout_otp' ),
				'wp_user_id'     => $user instanceof WP_User ? $user->ID : null,
				/*
				 * 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.
				 */
				'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.', 'texttango' ),
					PhoneNumber::mask( $phone )
				),
				'phone'   => $phone,
				'expires' => OtpService::ttl(),
			)
		);
	}

	/**
	 * Step two: check the code and remember the phone as verified.
	 */
	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'] ) ) : '';
		// 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(),
				)
			);
		}

		Throttle::clear( $phone );
		self::remember_verified( $phone );

		wp_send_json_success(
			array(
				'message' => __( 'Phone verified.', 'texttango' ),
				'masked'  => PhoneNumber::mask( $phone ),
			)
		);
	}

	/**
	 * Reject the request unless the gate is on and it is nonced.
	 *
	 * Deliberately asks nothing about who is making the request — checkout has
	 * to work for a guest, and anyone may request a code for any number they
	 * claim, exactly as the sign-in form already allows; possession is proved
	 * at the verify step, not the request step.
	 */
	private function guard(): void {
		if ( ! self::is_enabled() ) {
			wp_send_json_error( array( 'message' => __( 'Phone verification 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
			);
		}
	}

	/**
	 * WooCommerce's session store, when one is available.
	 *
	 * Guarded rather than assumed: the session is started by WooCommerce on
	 * `init`, and a request arriving before that — or a site with a broken
	 * session backend — must degrade to "nothing verified" rather than fatal.
	 */
	private static function session(): ?WC_Session_Handler {
		if ( ! function_exists( 'WC' ) ) {
			return null;
		}

		$session = WC()->session ?? null;

		return $session instanceof WC_Session_Handler ? $session : null;
	}

	/**
	 * Remember a phone as verified for the rest of this session.
	 *
	 * @param string $phone Verified number in E.164.
	 */
	public static function remember_verified( string $phone ): void {
		self::session()?->set( self::SESSION_KEY, $phone );
	}

	/**
	 * The phone verified in this session, if any.
	 */
	public static function verified_phone(): ?string {
		$session = self::session();

		if ( null === $session ) {
			return null;
		}

		$stored = $session->get( self::SESSION_KEY );

		return is_string( $stored ) && '' !== $stored ? $stored : null;
	}

	/**
	 * Forget the verified phone once an order has actually been placed.
	 *
	 * Declares no parameters even though both hooks it is registered on fire
	 * with one — WordPress only forwards as many as a callback declares, so
	 * there is nothing here for either caller's order object to bind to.
	 */
	public function clear_verified_session(): void {
		self::session()?->__unset( self::SESSION_KEY );
	}

	/**
	 * Whether a submitted phone matches the one verified in this session.
	 *
	 * Thin wrapper around {@see self::phones_match()} that supplies this
	 * request's verified phone from WooCommerce's session — kept separate so
	 * the actual comparison rule stays a pure function of its two arguments,
	 * testable without a session (or WooCommerce) behind it at all.
	 *
	 * @param string $submitted Phone as submitted at checkout, unnormalised.
	 */
	public static function phone_matches_verified( string $submitted ): bool {
		return self::phones_match( $submitted, self::verified_phone() );
	}

	/**
	 * The comparison rule itself, with both sides already resolved.
	 *
	 * Pure, so it can be tested without mocking `WP_Error` or WooCommerce. A
	 * submitted phone that fails to normalise is not this gate's problem —
	 * core's own required-field validation already raises that — so it is
	 * treated as "no opinion" rather than a failure.
	 *
	 * @param string      $submitted Phone as submitted at checkout, unnormalised.
	 * @param string|null $verified  The phone verified this session, already
	 *                               normalised, or null if none was.
	 */
	public static function phones_match( string $submitted, ?string $verified ): bool {
		$normalized = PhoneNumber::normalize( $submitted );

		if ( is_wp_error( $normalized ) ) {
			return true;
		}

		return $verified === $normalized;
	}

	/**
	 * Declare the code-entry field to WooCommerce.
	 *
	 * Block checkout only — the Additional Checkout Fields API does not render
	 * into the classic `[woocommerce_checkout]` shortcode, which is why
	 * {@see self::render_classic_widget()} exists as a separate, hand-rolled
	 * widget for that path.
	 */
	public function register_field(): void {
		if ( ! self::is_enabled() || ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {
			return;
		}

		woocommerce_register_additional_checkout_field(
			array(
				'id'       => self::FIELD_CODE,
				'label'    => __( 'Verification code', 'texttango' ),
				'location' => 'contact',
				'type'     => 'text',
				// Enforcement is server-side and does not read this field at all, so
				// it is never required by WooCommerce's own validation — a customer
				// who has not yet received a code must still be able to reach the
				// point of clicking "Send code".
				'required' => false,
			)
		);
	}

	/**
	 * Render the classic-checkout widget: a code field and a "Send code" trigger.
	 *
	 * Anchored on `woocommerce_after_checkout_billing_form` rather than
	 * `woocommerce_review_order_before_submit`, because the review fragment is
	 * wiped and re-rendered by WooCommerce's own AJAX on every shipping-method
	 * or coupon change — which would silently detach this widget's JS handlers
	 * on a checkout with more than one field to fill in.
	 */
	public function render_classic_widget(): void {
		if ( ! self::is_enabled() ) {
			return;
		}

		load_template(
			TEXTTANGO_PATH . 'templates/frontend/checkout-otp-classic.php',
			false,
			array( 'length' => OtpService::CODE_LENGTH )
		);
	}

	/**
	 * Enqueue the widget's script, only where checkout actually renders.
	 */
	public function maybe_enqueue_assets(): void {
		if ( ! self::is_enabled() || ! function_exists( 'is_checkout' ) || ! is_checkout() ) {
			return;
		}

		wp_enqueue_script(
			'texttango-checkout-otp',
			TEXTTANGO_URL . 'assets/frontend/checkout-otp.js',
			array(),
			TEXTTANGO_VERSION,
			true
		);

		wp_localize_script(
			'texttango-checkout-otp',
			'textTangoCheckoutOtp',
			array(
				'ajaxUrl' => admin_url( 'admin-ajax.php' ),
				'nonce'   => wp_create_nonce( self::NONCE ),
				'length'  => OtpService::CODE_LENGTH,
				'i18n'    => array(
					'sending'      => __( 'Sending…', 'texttango' ),
					'verifying'    => __( 'Verifying…', 'texttango' ),
					'sendCode'     => __( 'Send code', 'texttango' ),
					'resend'       => __( 'Send a new code', 'texttango' ),
					'verified'     => __( 'Verified.', 'texttango' ),
					'genericError' => __( 'Something went wrong. Please try again.', 'texttango' ),
					'needPhone'    => __( 'Enter your phone number first.', 'texttango' ),
				),
			)
		);
	}

	/**
	 * Block classic checkout unless the billing phone has been verified.
	 *
	 * Neither parameter is taken by reference: `$data` is only read, and
	 * `$errors->add()` mutates the object WooCommerce already holds a handle
	 * to, which needs no reference of its own.
	 *
	 * @param array<string, mixed> $data   Posted checkout data.
	 * @param WP_Error             $errors Validation errors.
	 */
	public function validate_classic( array $data, WP_Error $errors ): void {
		if ( ! self::is_enabled() ) {
			return;
		}

		$submitted = (string) ( $data['billing_phone'] ?? '' );

		if ( self::phone_matches_verified( $submitted ) ) {
			return;
		}

		$errors->add(
			'texttango_checkout_otp',
			__( 'Please verify your phone number with the code we texted you before placing your order.', 'texttango' )
		);
	}

	/**
	 * Block block-checkout order placement unless the billing phone has been
	 * verified.
	 *
	 * Declares only `$order`: registered with `$accepted_args = 1` (see
	 * `register()`), since by the time this fires `$order` already carries
	 * the billing address the request submitted, and the request itself is
	 * not needed.
	 *
	 * @param WC_Order $order The order the Store API is placing.
	 */
	public function validate_blocks( WC_Order $order ): void {
		if ( ! self::is_enabled() ) {
			return;
		}

		$submitted = $order->get_billing_phone();

		if ( self::phone_matches_verified( $submitted ) ) {
			return;
		}

		throw new RouteException(
			'texttango_checkout_otp_required',
			esc_html__( 'Please verify your phone number with the code we texted you before placing your order.', 'texttango' ),
			400
		);
	}

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