<?php
/**
 * Confirming marketing consent from the handset.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Consent;

use TextTango\Sending\Queue;
use TextTango\Sending\Sender;
use TextTango\Support\Clock;
use TextTango\Support\Settings;
use WC_Order;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Asks the number itself to confirm before it counts as consent (spec §7).
 *
 * A tick in a box says the person filling in the form wants messages. It does
 * not say they hold the number they typed. Double opt-in closes that gap: the
 * consent is recorded as `pending`, one message goes to the number, and only an
 * answer from the handset turns it into permission.
 *
 * ### Why this lives in WordPress
 *
 * The platform has an opt-in feature on contact groups, but it is single opt-in
 * with a welcome message — the contact is created on submission and the
 * `opt_in_message` is a greeting, not a question. Nothing there waits for an
 * answer. (Its genuine double opt-in, with a signed confirmation link, is
 * email-only.) So there was nothing to delegate to.
 *
 * ### Why a link and not "reply YES"
 *
 * Because a reply would never arrive. TextTango delivers no inbound SMS webhook
 * to a customer token — `message.received` is WhatsApp-only — so a site has no
 * way to hear a reply at all. A link back to this site is the one channel that
 * closes the loop with outbound messages alone.
 *
 * ### Why the link does not confirm on its own
 *
 * Tapping the link shows a page with a button; the consent is written on the
 * **POST**. Messaging apps, link scanners and mail security products routinely
 * fetch URLs they find in a message, and a GET that confirmed would let one of
 * them manufacture consent that nobody gave — in a record whose entire purpose
 * is to prove somebody did.
 *
 * ### What is deliberately not done
 *
 * Consent already on record is left alone when this is switched on. It was
 * validly collected under the rules in force at the time, and silently voiding a
 * merchant's whole list because a checkbox changed would be its own kind of
 * wrong. Only preferences recorded from here on are asked to confirm.
 */
final class DoubleOptIn {

	/**
	 * Event name for the confirmation message.
	 *
	 * Not in {@see Consent::ALWAYS_ALLOWED}: a number that has opted out must not
	 * be asked to opt back in. Not marketing either, so it does not require the
	 * consent it exists to ask for — {@see Consent::evaluate()} already lands on
	 * exactly that reading with no special case.
	 */
	public const EVENT = 'optin_confirm';

	/**
	 * Query variable carrying the token.
	 */
	public const QUERY_VAR = 'texttango_optin';

	/**
	 * Action Scheduler hook that issues and sends one confirmation.
	 */
	public const HOOK = 'texttango_request_optin';

	/**
	 * Prefix for pending-confirmation transients.
	 */
	private const PREFIX = 'texttango_optin_';

	/**
	 * How long a confirmation link stays usable, in seconds.
	 */
	public const TTL = 7 * DAY_IN_SECONDS;

	/**
	 * Characters in the public half of a token.
	 */
	private const SELECTOR_LENGTH = 8;

	/**
	 * Characters in the secret half.
	 */
	private const VERIFIER_LENGTH = 16;

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( self::HOOK, array( self::class, 'run' ), 10, 3 );
		add_action( 'template_redirect', array( $this, 'handle_request' ) );
	}

	/**
	 * Whether confirmation is required.
	 */
	public static function is_enabled(): bool {
		return (bool) Settings::get( 'consent_double_optin', false );
	}

	/**
	 * Begin confirmation for a registered customer.
	 *
	 * @param int    $user_id User id.
	 * @param string $source  Where the request was made.
	 */
	public static function start_for_user( int $user_id, string $source ): void {
		if ( ! self::should_request( Consent::state_for_user( $user_id ), self::recorded_at_for_user( $user_id ), Clock::now() ) ) {
			return;
		}

		// Written before the message goes out, so a request that is made but never
		// answered still shows on the record as something the customer asked for.
		Consent::write_user( $user_id, Consent::STATE_PENDING, $source );

		self::enqueue( $user_id, 0, Recipient::for_user( $user_id ) );
	}

	/**
	 * Begin confirmation for an order, and the account behind it if there is one.
	 *
	 * @param WC_Order $order  Order the consent was given on.
	 * @param string   $source Where the request was made.
	 */
	public static function start_for_order( WC_Order $order, string $source ): void {
		$user_id = $order->get_customer_id();

		if ( ! self::should_request( Consent::state_for_order( $order ), self::recorded_at_for_order( $order ), Clock::now() ) ) {
			return;
		}

		Consent::write_order( $order, Consent::STATE_PENDING, $source );

		if ( $user_id > 0 ) {
			Consent::write_user( $user_id, Consent::STATE_PENDING, $source );
		}

		self::enqueue( $user_id, $order->get_id(), Recipient::for_order( $order ) );
	}

	/**
	 * Whether a fresh confirmation should be sent.
	 *
	 * Pure, because the answer is a policy question and the two ways of getting it
	 * wrong are both bad: asking again on every save turns a confirmation into a
	 * pestering loop, and never asking again strands anybody whose first message
	 * failed. A live request is left to run its course; an expired one may be
	 * replaced.
	 *
	 * @param string $state       Current consent state.
	 * @param int    $recorded_at When that state was recorded; 0 if unknown.
	 * @param int    $now         Current timestamp.
	 */
	public static function should_request( string $state, int $recorded_at, int $now ): bool {
		// Already confirmed. Nothing to ask, and asking would imply otherwise.
		if ( Consent::STATE_GRANTED === $state ) {
			return false;
		}

		if ( Consent::STATE_PENDING !== $state ) {
			return true;
		}

		return $recorded_at <= 0 || ( $now - $recorded_at ) >= self::TTL;
	}

	/**
	 * Schedule the confirmation message.
	 *
	 * The token is minted in the worker rather than here. A queued message is sent
	 * from what the delivery log holds, so composing the link on this thread would
	 * write a live confirmation credential into the log — and anyone who could read
	 * it could confirm on the customer's behalf, producing a consent record
	 * indistinguishable from a real one.
	 *
	 * @param int    $user_id  User id, or 0.
	 * @param int    $order_id Order id, or 0.
	 * @param string $phone    E.164 recipient.
	 */
	private static function enqueue( int $user_id, int $order_id, string $phone ): void {
		if ( '' === $phone ) {
			// The request is on the record; there is simply no number to ask. The
			// customer can complete it later by saving a number and asking again.
			return;
		}

		if ( ! Queue::is_available() ) {
			self::run( $user_id, $order_id, $phone );

			return;
		}

		as_enqueue_async_action( self::HOOK, array( $user_id, $order_id, $phone ), Queue::GROUP );
	}

	/**
	 * Mint a token and send the confirmation. Entry point for the queue.
	 *
	 * @param mixed $user_id  User id, or 0.
	 * @param mixed $order_id Order id, or 0.
	 * @param mixed $phone    E.164 recipient.
	 */
	public static function run( $user_id, $order_id, $phone ): void {
		$phone = (string) $phone;

		if ( '' === $phone ) {
			return;
		}

		$token   = self::issue( (int) $user_id, (int) $order_id, $phone );
		$message = self::compose_message( self::link( $token ) );

		Sender::send(
			array(
				'to'         => $phone,
				'message'    => $message,
				// The token is a live credential. Redacted for the same reason a
				// login code is: the log must not hold something that grants what
				// the message grants.
				'log_body'   => str_replace( $token, '••••••', $message ),
				'event'      => self::EVENT,
				'dedupe_key' => self::EVENT . ':' . self::split( $token )['selector'],
				'wp_user_id' => (int) $user_id > 0 ? (int) $user_id : null,
				'order_id'   => (int) $order_id > 0 ? (int) $order_id : null,
			)
		);
	}

	/**
	 * Create a pending confirmation and return its token.
	 *
	 * Split into a public selector and a secret verifier. The selector names the
	 * record so it can be found in one read; only a hash of the verifier is stored,
	 * so the stored record cannot be turned back into a working link.
	 *
	 * @param int    $user_id  User id, or 0.
	 * @param int    $order_id Order id, or 0.
	 * @param string $phone    E.164 recipient.
	 * @return string The token to put in the link.
	 */
	public static function issue( int $user_id, int $order_id, string $phone ): string {
		$selector = wp_generate_password( self::SELECTOR_LENGTH, false );
		$verifier = wp_generate_password( self::VERIFIER_LENGTH, false );

		set_transient(
			self::PREFIX . $selector,
			array(
				'hash'      => self::hash( $verifier ),
				'user_id'   => $user_id,
				'order_id'  => $order_id,
				'phone'     => $phone,
				'issued_at' => Clock::now(),
			),
			self::TTL
		);

		return $selector . '.' . $verifier;
	}

	/**
	 * Look a token up without spending it.
	 *
	 * @param string $token Token from the link.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function lookup( string $token ) {
		$parts = self::split( $token );

		if ( '' === $parts['selector'] || '' === $parts['verifier'] ) {
			return new WP_Error( 'texttango_optin_invalid', self::expired_message() );
		}

		$record = get_transient( self::PREFIX . $parts['selector'] );

		if ( ! is_array( $record ) || empty( $record['hash'] ) ) {
			return new WP_Error( 'texttango_optin_expired', self::expired_message() );
		}

		if ( ! hash_equals( (string) $record['hash'], self::hash( $parts['verifier'] ) ) ) {
			return new WP_Error( 'texttango_optin_invalid', self::expired_message() );
		}

		return $record;
	}

	/**
	 * Spend a token and record the consent it confirms.
	 *
	 * @param string $token Token from the link.
	 * @return array<string, mixed>|WP_Error The record that was confirmed.
	 */
	public static function confirm( string $token ) {
		$record = self::lookup( $token );

		if ( is_wp_error( $record ) ) {
			return $record;
		}

		// Single use, spent before anything is written, so a link forwarded to
		// somebody else cannot be replayed.
		delete_transient( self::PREFIX . self::split( $token )['selector'] );

		$user_id  = (int) ( $record['user_id'] ?? 0 );
		$order_id = (int) ( $record['order_id'] ?? 0 );

		if ( $order_id > 0 ) {
			$order = wc_get_order( $order_id );

			if ( $order instanceof WC_Order ) {
				Consent::write_order( $order, Consent::STATE_GRANTED, 'confirmed' );
			}
		}

		if ( $user_id > 0 ) {
			Consent::write_user( $user_id, Consent::STATE_GRANTED, 'confirmed' );
		}

		/**
		 * Fires when a number confirms its own consent.
		 *
		 * @param string               $phone  E.164 recipient that confirmed.
		 * @param array<string, mixed> $record The pending record that was spent.
		 */
		do_action( 'texttango_optin_confirmed', (string) ( $record['phone'] ?? '' ), $record );

		return $record;
	}

	/**
	 * Split a token into its public and secret halves.
	 *
	 * @param string $token Token as it arrived.
	 * @return array{selector: string, verifier: string}
	 */
	public static function split( string $token ): array {
		$parts = explode( '.', trim( $token ), 2 );

		$clean = static fn( string $part ): string => (string) preg_replace( '/[^A-Za-z0-9]/', '', $part );

		return array(
			'selector' => $clean( $parts[0] ?? '' ),
			'verifier' => $clean( $parts[1] ?? '' ),
		);
	}

	/**
	 * The confirmation URL for a token.
	 *
	 * @param string $token Token.
	 */
	public static function link( string $token ): string {
		return add_query_arg( self::QUERY_VAR, rawurlencode( $token ), home_url( '/' ) );
	}

	/**
	 * Body of the confirmation message.
	 *
	 * @param string $link Confirmation URL.
	 */
	public static function compose_message( string $link ): string {
		$template = trim( (string) Settings::get( 'consent_confirm_template', '' ) );

		if ( '' === $template ) {
			$template = self::default_template();
		}

		return strtr(
			$template,
			array(
				'{confirm_link}' => $link,
				'{store_name}'   => wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ),
			)
		);
	}

	/**
	 * The wording used when the merchant has not written their own.
	 *
	 * Short on purpose. A URL is most of an SMS segment before a word is written,
	 * and a confirmation that costs two messages to send costs it on every opt-in
	 * the store ever collects.
	 */
	public static function default_template(): string {
		return __( 'Confirm SMS updates from {store_name}: {confirm_link}', 'texttango' );
	}

	/**
	 * Deal with a confirmation link being opened.
	 */
	public function handle_request(): void {
		/*
		 * No nonce. The token is itself the credential, and the request arrives
		 * from a text message with no prior session to bind a nonce to. The POST
		 * step exists to defeat link prefetching, not cross-site requests — anyone
		 * who could forge the request would already hold the token, and could
		 * simply open the link themselves.
		 */
		// phpcs:disable WordPress.Security.NonceVerification
		$token = isset( $_REQUEST[ self::QUERY_VAR ] )
			? sanitize_text_field( wp_unslash( $_REQUEST[ self::QUERY_VAR ] ) )
			: '';

		$submitted = isset( $_POST[ self::QUERY_VAR ] );
		// phpcs:enable WordPress.Security.NonceVerification

		if ( '' === $token ) {
			return;
		}

		$state = $submitted
			? self::state_after_confirm( $token )
			: self::state_before_confirm( $token );

		self::render( $state, $token );
	}

	/**
	 * What to show for a link that has only been opened.
	 *
	 * @param string $token Token from the link.
	 */
	private static function state_before_confirm( string $token ): string {
		return is_wp_error( self::lookup( $token ) ) ? 'expired' : 'confirm';
	}

	/**
	 * What to show once the button has been pressed.
	 *
	 * @param string $token Token from the link.
	 */
	private static function state_after_confirm( string $token ): string {
		return is_wp_error( self::confirm( $token ) ) ? 'expired' : 'confirmed';
	}

	/**
	 * Render the confirmation page and stop.
	 *
	 * `wp_die()` rather than the theme, following what core itself does for the
	 * equivalent endpoint — the link that confirms a privacy request in
	 * `wp-login.php`. It is opened by tapping a link in a text message, so it wants
	 * to be one small readable thing that loads on a phone, on every theme, without
	 * a menu or a cart in the way, and with no assets to get wrong.
	 *
	 * @param string $state One of 'confirm', 'confirmed', 'expired'.
	 * @param string $token Token from the link.
	 */
	private static function render( string $state, string $token ): void {
		nocache_headers();

		// Every fragment below is escaped as it is composed; the assembled string
		// is markup by then, so it is handed to wp_die() as-is.
		$home = sprintf(
			'<p><a href="%1$s">%2$s</a></p>',
			esc_url( home_url( '/' ) ),
			esc_html( get_bloginfo( 'name' ) )
		);

		if ( 'confirmed' === $state ) {
			$body  = '<p>' . esc_html__( 'Thank you — your number is confirmed. You can change your mind at any time from your account.', 'texttango' ) . '</p>';
			$title = __( 'Subscription confirmed', 'texttango' );
			$code  = 200;
		} elseif ( 'expired' === $state ) {
			$body  = '<p>' . esc_html( self::expired_message() ) . '</p>';
			$title = __( 'Link no longer valid', 'texttango' );
			$code  = 410;
		} else {
			/*
			 * A button, not an automatic confirmation. See the class docblock:
			 * anything that fetches the URL on the recipient's behalf must not be
			 * able to answer for them.
			 */
			$body = sprintf(
				'<p>%1$s</p><form method="post" action="%2$s"><input type="hidden" name="%3$s" value="%4$s" /><p><button type="submit">%5$s</button></p></form>',
				esc_html__( 'Please confirm that you would like to receive text messages from us.', 'texttango' ),
				esc_url( self::link( $token ) ),
				esc_attr( self::QUERY_VAR ),
				esc_attr( $token ),
				esc_html__( 'Yes, confirm', 'texttango' )
			);

			$title = __( 'Confirm your subscription', 'texttango' );
			$code  = 200;
		}

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Composed from escaped fragments immediately above.
		wp_die( $body . $home, esc_html( $title ), array( 'response' => $code ) );
	}

	/**
	 * The one thing said about a token that does not work.
	 *
	 * Expired, already used, mistyped and never issued all read the same, so the
	 * page cannot be used to find out whether a given number was ever asked.
	 */
	public static function expired_message(): string {
		return __( 'This link has expired or has already been used.', 'texttango' );
	}

	/**
	 * Keyed hash of a verifier.
	 *
	 * @param string $verifier Secret half of a token.
	 */
	private static function hash( string $verifier ): string {
		return hash_hmac( 'sha256', $verifier, wp_salt( 'secure_auth' ) );
	}

	/**
	 * When a user's preference was last recorded, as a timestamp.
	 *
	 * @param int $user_id User id.
	 */
	private static function recorded_at_for_user( int $user_id ): int {
		return self::to_timestamp( (string) get_user_meta( $user_id, Consent::META_OPTIN_AT, true ) );
	}

	/**
	 * When an order's preference was recorded, as a timestamp.
	 *
	 * @param WC_Order $order Order.
	 */
	private static function recorded_at_for_order( WC_Order $order ): int {
		return self::to_timestamp( (string) $order->get_meta( Consent::ORDER_META_OPTIN_AT, true ) );
	}

	/**
	 * Read a stored UTC datetime back into a timestamp.
	 *
	 * @param string $value Stored `Y-m-d H:i:s` in UTC.
	 */
	private static function to_timestamp( string $value ): int {
		if ( '' === trim( $value ) ) {
			return 0;
		}

		$parsed = strtotime( $value . ' UTC' );

		return false === $parsed ? 0 : (int) $parsed;
	}
}
