<?php
/**
 * Email as a last resort when an SMS will not arrive.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Sending;

use TextTango\Messages\MessageLog;
use TextTango\Support\Settings;
use WC_Order;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Sends the same message by email when the SMS could not be delivered (spec §19).
 *
 * A customer whose dispatch notice never arrives does not know their order
 * shipped, and the store finds out through a support ticket. When the number
 * turns out to be a landline, or the carrier rejects it, the information is
 * still worth getting to them by whatever route is left.
 *
 * ### What it must never do
 *
 * **Fall back for a message that was deliberately not sent.** Somebody who
 * opted out of SMS has not opted into email, and a suppression is a refusal
 * rather than a delivery problem. Emailing them instead would be routing around
 * the customer's decision using the machinery built to respect it. Skipped
 * messages never reach this class, and `should_fall_back()` refuses them again
 * anyway, because the cost of being wrong here is not a wasted email.
 *
 * **Fall back for a sign-in or two-factor code.** A code sent to a phone is
 * something you have; the same code sent to an inbox is something else
 * entirely, often reachable from the very device the second factor exists to
 * protect. Making that swap automatically, on a failure, would quietly downgrade
 * everybody's authentication at the worst moment.
 *
 * **Fall back on a timeout.** The platform may well have accepted and delivered
 * it. Emailing as well would send the same thing twice and leave the customer
 * unsure which to believe.
 */
final class EmailFallback {

	/**
	 * Prefix for the "already emailed" marker.
	 */
	private const SENT_PREFIX = 'texttango_fallback_';

	/**
	 * How long that marker lives.
	 *
	 * Long enough that a retry days later cannot produce a second email, short
	 * enough that it clears itself without a scheduled job.
	 */
	private const SENT_TTL = WEEK_IN_SECONDS;

	/**
	 * Events that must never be emailed instead.
	 *
	 * @var string[]
	 */
	public const NEVER = array(
		'otp',
		'otp_2fa',
		'phone_verification',
		'optin_confirm',
		// The whole point is proving the customer holds this phone number.
		// Emailing the code instead would prove nothing and defeats the gate.
		'checkout_otp',
		// WordPress has already emailed this exact link. A fallback would either
		// duplicate that mail or, worse, send it to an address the customer told
		// us they cannot read — which is the whole reason the SMS exists.
		'password_reset',
	);

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'texttango_send_failed', array( $this, 'on_send_failed' ), 10, 2 );
		add_action( 'texttango_webhook_received', array( $this, 'on_receipt' ), 10, 2 );
	}

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

	/**
	 * Whether an event may ever be emailed instead.
	 *
	 * Pure. Codes are excluded for the reason in the class docblock, and
	 * marketing because somebody who agreed to texts has not thereby agreed to
	 * email — the consent that permits one does not carry to the other.
	 *
	 * @param string $event Event id.
	 */
	public static function applies_to( string $event ): bool {
		if ( in_array( $event, self::NEVER, true ) ) {
			return false;
		}

		return Budget::TIER_MARKETING !== Budget::tier( $event );
	}

	/**
	 * Whether a particular failure is worth falling back on.
	 *
	 * Pure, so the whole rule sits in one readable place: what failed, whether
	 * it is going to be tried again, and whether it failed at all.
	 *
	 * @param string $status     Status the row now holds.
	 * @param string $error_code Error code recorded against it.
	 * @param bool   $will_retry Whether the queue intends to try again.
	 * @return bool
	 */
	public static function should_fall_back( string $status, string $error_code, bool $will_retry ): bool {
		// Still in flight. Emailing now would arrive before the SMS that is about
		// to succeed.
		if ( $will_retry ) {
			return false;
		}

		/*
		 * A deliberate non-send. This can only be reached by mistake — skipped
		 * messages do not fire the hooks this class listens on — and it is checked
		 * anyway because the failure mode is emailing somebody who opted out.
		 */
		if ( MessageLog::STATUS_SKIPPED === $status ) {
			return false;
		}

		// The platform may have accepted it. See the class docblock.
		if ( MessageLog::STATUS_TIMEOUT === $status || 'texttango_timeout' === $error_code ) {
			return false;
		}

		return MessageLog::STATUS_FAILED === $status;
	}

	/**
	 * A send that failed outright.
	 *
	 * @param int      $message_id Log row id.
	 * @param WP_Error $error      What went wrong.
	 */
	public function on_send_failed( $message_id, $error ): void {
		$code = $error instanceof WP_Error ? (string) $error->get_error_code() : '';

		$this->consider( (int) $message_id, $code, Queue::is_retryable( $code ) );
	}

	/**
	 * A delivery receipt saying the carrier could not deliver it.
	 *
	 * The commonest real failure: the platform accepted the message, so nothing
	 * looked wrong at the time, and the bad news arrives minutes later.
	 *
	 * @param int    $message_id Log row id.
	 * @param string $status     Status the receipt applied.
	 */
	public function on_receipt( $message_id, $status ): void {
		if ( MessageLog::STATUS_FAILED !== (string) $status ) {
			return;
		}

		$this->consider( (int) $message_id, '', false );
	}

	/**
	 * Decide, and send if the answer is yes.
	 *
	 * @param int    $message_id Log row id.
	 * @param string $error_code Error code, when there is one.
	 * @param bool   $will_retry Whether the queue intends to try again.
	 */
	private function consider( int $message_id, string $error_code, bool $will_retry ): void {
		if ( ! self::is_enabled() || $message_id <= 0 ) {
			return;
		}

		$row = MessageLog::find( $message_id );

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

		if ( ! self::applies_to( (string) $row['event'] ) ) {
			return;
		}

		if ( ! self::should_fall_back( (string) $row['status'], $error_code, $will_retry ) ) {
			return;
		}

		if ( self::already_sent( $message_id ) ) {
			return;
		}

		$to = self::address_for( $row );

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

		self::mark_sent( $message_id );
		self::deliver( $to, $row );
	}

	/**
	 * Send the email and note it on the row.
	 *
	 * @param string               $to  Address.
	 * @param array<string, mixed> $row Log row.
	 */
	private static function deliver( string $to, array $row ): void {
		$subject = sprintf(
			/* translators: %s: the site name. */
			__( 'A message from %s', 'texttango' ),
			wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES )
		);

		$sent = wp_mail(
			$to,
			$subject,
			(string) $row['body'] . "\n\n" . __( 'We tried to text you this and could not reach your number, so we have emailed it instead.', 'texttango' )
		);

		MessageLog::update(
			(int) $row['id'],
			array(
				'error_reason' => trim(
					(string) $row['error_reason'] . ' ' . (
						$sent
							? __( 'Sent by email instead.', 'texttango' )
							: __( 'Email fallback also failed.', 'texttango' )
					)
				),
			)
		);

		/**
		 * Fires after an undeliverable SMS has been retried as an email.
		 *
		 * @since 0.2.0
		 *
		 * @param int    $message_id Log row id.
		 * @param string $to         Address it went to.
		 * @param bool   $sent       Whether `wp_mail()` accepted it.
		 */
		do_action( 'texttango_email_fallback', (int) $row['id'], $to, (bool) $sent );
	}

	/**
	 * Where to email, given what the row knows about the recipient.
	 *
	 * The order first, because it holds the address the customer gave for this
	 * purchase — which is the one they are watching when they wonder where their
	 * order is.
	 *
	 * @param array<string, mixed> $row Log row.
	 */
	public static function address_for( array $row ): string {
		$order_id = (int) ( $row['order_id'] ?? 0 );

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

			if ( $order instanceof WC_Order ) {
				$email = trim( (string) $order->get_billing_email() );

				if ( '' !== $email && is_email( $email ) ) {
					return $email;
				}
			}
		}

		$user_id = (int) ( $row['wp_user_id'] ?? 0 );

		if ( $user_id > 0 ) {
			$user = get_userdata( $user_id );

			if ( $user && is_email( $user->user_email ) ) {
				return (string) $user->user_email;
			}
		}

		/*
		 * Staff alerts have neither. They are addressed to numbers a merchant typed
		 * into the settings screen, so the site's own admin address is the only
		 * thing that stands for the same audience.
		 */
		if ( 'new_order_admin' === (string) ( $row['event'] ?? '' ) ) {
			$admin = (string) get_option( 'admin_email', '' );

			return is_email( $admin ) ? $admin : '';
		}

		return '';
	}

	/**
	 * Whether this message has already been emailed.
	 *
	 * @param int $message_id Log row id.
	 */
	private static function already_sent( int $message_id ): bool {
		return false !== get_transient( self::SENT_PREFIX . $message_id );
	}

	/**
	 * Note that it has, before sending rather than after.
	 *
	 * Two workers can pick up the same failure; marking first means the race
	 * produces no email rather than two, and a lost email is better than a
	 * customer receiving the same thing twice.
	 *
	 * @param int $message_id Log row id.
	 */
	private static function mark_sent( int $message_id ): void {
		set_transient( self::SENT_PREFIX . $message_id, 1, self::SENT_TTL );
	}
}
