<?php
/**
 * The single path every outbound message takes.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Sending;

use TextTango\Api\Client;
use TextTango\Api\PhoneNumber;
use TextTango\Consent\Consent;
use TextTango\Consent\QuietHours;
use TextTango\Support\Clock;
use TextTango\Messages\MessageLog;
use TextTango\Support\Sandbox;
use TextTango\Support\Settings;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Normalizes, deduplicates, sandboxes, sends, and logs.
 *
 * Everything that sends an SMS — OTP, order notifications, the test button —
 * goes through {@see Sender::send()}. Keeping one path means the sandbox check,
 * the dedupe claim, and the delivery log cannot be forgotten by a caller added
 * later.
 *
 * Order matters here. The number is validated *before* the row is claimed, so an
 * unusable number never consumes its dedupe key; and the row is claimed *before*
 * the API is called, so a duplicate event can never reach the API at all.
 */
final class Sender {

	/**
	 * Send a message.
	 *
	 * @param array<string, mixed> $args {
	 *     Message to send.
	 *
	 *     @type string   $to             Required. Recipient, raw or E.164.
	 *     @type string   $message        Required. Body actually sent.
	 *     @type string   $log_body       Body written to the delivery log, when it
	 *                                    must differ from what was sent. Defaults
	 *                                    to `$message`.
	 *     @type string   $event          Required. Event name, e.g. 'test', 'otp'.
	 *     @type string   $dedupe_key     Idempotency key. Defaults to a unique one.
	 *     @type string   $country        ISO country for normalization.
	 *     @type string   $from           Sender ID. Defaults to the site setting.
	 *     @type int|null $order_id       Related WooCommerce order.
	 *     @type int|null $wp_user_id     Related WordPress user.
	 *     @type bool     $bypass_sandbox Send for real even when sandboxed.
	 * }
	 * @return array<string, mixed>|WP_Error Result with `message_id`, `status` and
	 *                                       `uuid`, or an error.
	 */
	public static function send( array $args ) {
		$claim = self::claim( $args );

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

		if ( $claim['sandboxed'] ) {
			return self::handle_sandboxed( $claim['message_id'], $claim['recipient'], $claim['message'], $claim['from'] );
		}

		return self::dispatch( $claim['message_id'], $claim['recipient'], $claim['message'], $claim['from'] );
	}

	/**
	 * The arguments a caller outside this plugin may set.
	 *
	 * `bypass_sandbox` and `log_body` are missing on purpose, and that is a
	 * security boundary rather than tidiness. `bypass_sandbox` would let any
	 * plugin on the site send real messages to real customers from a staging
	 * clone — the exact thing sandbox mode exists to prevent. `log_body` would
	 * let a caller choose what the delivery log remembers about what it sent,
	 * which is the one record a merchant has to answer for a message.
	 *
	 * Unknown keys are dropped rather than rejected, so a caller passing extra
	 * context of their own is harmless.
	 *
	 * @param array<string, mixed> $args Arguments as supplied by the caller.
	 * @return array<string, mixed>
	 */
	public static function public_args( array $args ): array {
		return array_intersect_key(
			$args,
			array_flip(
				array( 'event', 'dedupe_key', 'country', 'from', 'order_id', 'wp_user_id' )
			)
		);
	}

	/**
	 * Reserve a message now and deliver it in the background.
	 *
	 * The dedupe row is written on the request thread, so a duplicate event is
	 * refused immediately and never reaches the queue; only the HTTP call is
	 * deferred. That ordering matters — deferring the claim as well would let two
	 * simultaneous hooks both enqueue before either had reserved the key.
	 *
	 * Unlike {@see self::send()} this accepts no `log_body`. A queued message is
	 * sent from what the log holds, so a redacted body would be the thing that
	 * actually went out.
	 *
	 * @param array<string, mixed> $args Same shape as {@see self::send()}, minus `log_body`.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function queue( array $args ) {
		unset( $args['log_body'] );

		$claim = self::claim( $args );

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

		// Sandbox resolves here rather than in the worker: there is nothing to
		// defer when no request is going to be made.
		if ( $claim['sandboxed'] ) {
			return self::handle_sandboxed( $claim['message_id'], $claim['recipient'], $claim['message'], $claim['from'] );
		}

		/*
		 * A quiet window holds the message rather than dropping it. An order
		 * update that would land at 03:00 is still worth delivering at 08:00, and
		 * discarding it would cost the customer information they need.
		 */
		$delay = QuietHours::delay_for( (string) ( $args['event'] ?? '' ) );

		if ( $delay > 0 ) {
			MessageLog::update(
				$claim['message_id'],
				array(
					'error_reason' => sprintf(
						/* translators: %s: local time the quiet window opens. */
						__( 'Held until %s — quiet hours.', 'texttango' ),
						wp_date( get_option( 'time_format', 'H:i' ), Clock::now() + $delay )
					),
				)
			);
		}

		$scheduled = Queue::enqueue( $claim['message_id'], $delay );

		return array(
			'message_id' => $claim['message_id'],
			'status'     => $scheduled ? MessageLog::STATUS_QUEUED : MessageLog::STATUS_SENT,
			'uuid'       => '',
			'sandboxed'  => false,
			'queued'     => $scheduled,
			'held_until' => $delay > 0 ? Clock::now() + $delay : 0,
		);
	}

	/**
	 * Deliver a message that was queued earlier. Entry point for the worker.
	 *
	 * Idempotent by necessity: Action Scheduler guarantees a job runs at least
	 * once, not exactly once, so a settled row is left alone.
	 *
	 * @param int $message_id Log row id.
	 */
	public static function deliver( int $message_id ): void {
		$row = MessageLog::find( $message_id );

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

		if ( Queue::is_settled( (string) $row['status'] ) ) {
			return;
		}

		$result = self::dispatch(
			$message_id,
			(string) $row['recipient'],
			(string) $row['body'],
			(string) $row['sender_id']
		);

		if ( ! is_wp_error( $result ) ) {
			return;
		}

		self::maybe_retry( $message_id, $row, $result );
	}

	/**
	 * Reschedule a failed delivery, if the failure is one worth repeating.
	 *
	 * @param int                  $message_id Log row id.
	 * @param array<string, mixed> $row        Row as it was before this attempt.
	 * @param WP_Error             $error      Why the attempt failed.
	 */
	private static function maybe_retry( int $message_id, array $row, WP_Error $error ): void {
		if ( ! Queue::is_retryable( (string) $error->get_error_code() ) ) {
			return;
		}

		$attempts = (int) ( $row['attempts'] ?? 0 ) + 1;
		$backoff  = Queue::backoff_for( $attempts );

		MessageLog::update( $message_id, array( 'attempts' => $attempts ) );

		if ( null === $backoff ) {
			// Out of attempts; the row keeps its failed status and reason.
			return;
		}

		$data = $error->get_error_data();

		if ( is_array( $data ) && ! empty( $data['retry_after'] ) ) {
			$backoff = max( $backoff, (int) $data['retry_after'] );
		}

		MessageLog::update( $message_id, array( 'status' => MessageLog::STATUS_QUEUED ) );

		Queue::enqueue( $message_id, $backoff );
	}

	/**
	 * Validate a message and reserve its row.
	 *
	 * @param array<string, mixed> $args Caller arguments.
	 * @return array{message_id: int, recipient: string, from: string, message: string, sandboxed: bool}|WP_Error
	 */
	private static function claim( array $args ) {
		$args = wp_parse_args(
			$args,
			array(
				'to'             => '',
				'message'        => '',
				'log_body'       => null,
				'event'          => 'manual',
				'dedupe_key'     => '',
				'country'        => null,
				'from'           => null,
				'order_id'       => null,
				'wp_user_id'     => null,
				'bypass_sandbox' => false,
			)
		);

		if ( '' === trim( (string) $args['message'] ) ) {
			return new WP_Error( 'texttango_empty_message', __( 'Cannot send an empty message.', 'texttango' ) );
		}

		$recipient = PhoneNumber::normalize( (string) $args['to'], $args['country'] );

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

		$sandboxed = ! $args['bypass_sandbox'] && Sandbox::is_active();
		$dedupe    = '' !== (string) $args['dedupe_key']
			? (string) $args['dedupe_key']
			: MessageLog::unique_dedupe_key( (string) $args['event'] );

		$verdict = Consent::decide(
			(string) $args['event'],
			$recipient,
			null !== $args['wp_user_id'] ? (int) $args['wp_user_id'] : null
		);

		/**
		 * Filters whether a message should be sent at all (spec §18).
		 *
		 * Applied here, at the one point every message passes through, so a site
		 * that needs to stop something cannot be defeated by a caller added later
		 * taking a different route.
		 *
		 * That reach is the whole point and also the whole danger: this gates
		 * **sign-in codes and two-factor codes** as well as marketing. Returning
		 * false unconditionally locks people out of their accounts. Branch on
		 * `$event`.
		 *
		 * @since 0.2.0
		 *
		 * @param bool                 $send  Whether to send.
		 * @param string               $event Event name, e.g. `order_completed`, `otp`.
		 * @param array<string, mixed> $args  The full send arguments, including
		 *                                    `to`, `message`, `order_id` and `wp_user_id`.
		 */
		if ( true === $verdict && ! apply_filters( 'texttango_should_send', true, (string) $args['event'], $args ) ) {
			$verdict = 'filtered';
		}

		/*
		 * Ceilings last, after consent and the filter, so a message that was never
		 * going to be sent does not consume any part of a budget. Sandboxed
		 * messages cost nothing, so they are not counted and not capped.
		 */
		if ( true === $verdict && ! $sandboxed ) {
			$budget = Budget::check( (string) $args['event'], $recipient );

			if ( true !== $budget ) {
				$verdict = $budget;
			}
		}

		$from = null !== $args['from'] ? (string) $args['from'] : (string) Settings::get( 'sender_id', '' );

		$message_id = MessageLog::claim(
			array(
				'dedupe_key' => $dedupe,
				'event'      => (string) $args['event'],
				'recipient'  => $recipient,
				'sender_id'  => $from,
				/*
				 * The log keeps what the caller says is safe to keep, which is not
				 * always what was sent. An OTP body carries a live credential, and
				 * storing it would undo the point of hashing the code — anyone able
				 * to read the table could sign in as anyone (spec §19).
				 */
				'body'       => null !== $args['log_body'] ? (string) $args['log_body'] : (string) $args['message'],
				'order_id'   => $args['order_id'],
				'wp_user_id' => $args['wp_user_id'],
				'status'     => MessageLog::STATUS_QUEUED,
				'sandbox'    => $sandboxed ? 1 : 0,
			)
		);

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

		/*
		 * Recorded as skipped rather than dropped silently, so a merchant can see
		 * in the log that a customer was deliberately not messaged, and why. The
		 * row is claimed first so the dedupe key is spent: this event has been
		 * decided, and a retried hook must not reconsider it.
		 */
		if ( true !== $verdict ) {
			MessageLog::update(
				$message_id,
				array(
					'status'       => MessageLog::STATUS_SKIPPED,
					'error_code'   => (string) $verdict,
					'error_reason' => self::skip_reason( (string) $verdict ),
				)
			);

			return new WP_Error(
				'texttango_blocked',
				self::skip_reason( (string) $verdict ),
				array(
					'reason'     => (string) $verdict,
					'message_id' => $message_id,
				)
			);
		}

		return array(
			'message_id' => $message_id,
			'recipient'  => $recipient,
			'from'       => $from,
			'message'    => (string) $args['message'],
			'sandboxed'  => $sandboxed,
		);
	}

	/**
	 * A readable explanation for a message that was deliberately not sent.
	 *
	 * @param string $reason Machine-readable reason from the consent gate.
	 */
	private static function skip_reason( string $reason ): string {
		if ( 'suppressed' === $reason ) {
			return __( 'Recipient has opted out of messages from this site.', 'texttango' );
		}

		if ( 'filtered' === $reason ) {
			return __( 'Stopped by this site: something is filtering texttango_should_send.', 'texttango' );
		}

		if ( Budget::owns_reason( $reason ) ) {
			return Budget::reason_text( $reason );
		}

		return __( 'Recipient has not opted in to marketing messages.', 'texttango' );
	}

	/**
	 * Deal with a message while sandbox is on.
	 *
	 * @param int    $message_id Log row id.
	 * @param string $recipient  E.164 recipient.
	 * @param string $message    Body.
	 * @param string $from       Sender ID.
	 * @return array<string, mixed>|WP_Error
	 */
	private static function handle_sandboxed( int $message_id, string $recipient, string $message, string $from ) {
		$test_number = Sandbox::test_number();

		if ( Sandbox::BEHAVIOR_REDIRECT === Sandbox::behavior() && '' !== $test_number ) {
			$redirected = PhoneNumber::normalize( $test_number );

			if ( ! is_wp_error( $redirected ) ) {
				MessageLog::update(
					$message_id,
					array(
						'recipient'    => $redirected,
						'error_reason' => sprintf(
							/* translators: %s: the original recipient in E.164. */
							__( 'Sandbox redirect: originally addressed to %s', 'texttango' ),
							$recipient
						),
					)
				);

				return self::dispatch( $message_id, $redirected, $message, $from );
			}
		}

		MessageLog::update(
			$message_id,
			array(
				'status'       => MessageLog::STATUS_SANDBOX,
				'error_reason' => __( 'Sandbox mode: logged, not sent.', 'texttango' ),
			)
		);

		/**
		 * Fires after a message is recorded but deliberately not sent.
		 *
		 * @param int    $message_id Log row id.
		 * @param string $recipient  E.164 recipient.
		 * @param string $message    Body.
		 */
		do_action( 'texttango_sandbox_send', $message_id, $recipient, $message );

		return array(
			'message_id' => $message_id,
			'status'     => MessageLog::STATUS_SANDBOX,
			'uuid'       => '',
			'sandboxed'  => true,
		);
	}

	/**
	 * Hand a message to the API and record what came back.
	 *
	 * @param int    $message_id Log row id.
	 * @param string $recipient  E.164 recipient.
	 * @param string $message    Body.
	 * @param string $from       Sender ID.
	 * @return array<string, mixed>|WP_Error
	 */
	private static function dispatch( int $message_id, string $recipient, string $message, string $from ) {
		/*
		 * Checked before the client is even built. While the breaker is open there
		 * is nothing to gain from another fifteen-second wait, and a queue of
		 * workers each holding one open is how an outage at TextTango turns into a
		 * slow site here.
		 */
		if ( CircuitBreaker::is_open() ) {
			return self::record_failure( $message_id, CircuitBreaker::error() );
		}

		$client = Client::from_settings();

		if ( is_wp_error( $client ) ) {
			return self::record_failure( $message_id, $client );
		}

		MessageLog::update( $message_id, array( 'status' => MessageLog::STATUS_SENDING ) );

		/**
		 * Fires immediately before a message is handed to TextTango (spec §18).
		 *
		 * The last point at which nothing has been spent. Paired with
		 * `texttango_after_send`, which fires once the platform has accepted it.
		 * To stop a message rather than observe it, use `texttango_should_send`.
		 *
		 * @since 0.2.0
		 *
		 * @param int    $message_id Delivery-log row id.
		 * @param string $recipient  Recipient in E.164.
		 * @param string $message    Body being sent.
		 * @param string $from       Sender ID.
		 */
		do_action( 'texttango_before_send', $message_id, $recipient, $message, $from );

		$response = $client->send_transactional( $recipient, $message, '' !== $from ? $from : null );

		if ( is_wp_error( $response ) ) {
			CircuitBreaker::record_failure( $response );

			return self::record_failure( $message_id, $response );
		}

		// The platform answered, so whatever the breaker was counting is over.
		CircuitBreaker::record_success();

		$attributes = $response['data']['attributes'] ?? $response['data'] ?? $response;
		$attributes = is_array( $attributes ) ? $attributes : array();

		$uuid = (string) ( $response['data']['id'] ?? $attributes['uuid'] ?? $attributes['id'] ?? $attributes['message_id'] ?? '' );

		$update = array(
			'status'                 => MessageLog::STATUS_SENT,
			'texttango_message_uuid' => $uuid,
		);

		if ( isset( $attributes['cost'] ) && is_numeric( $attributes['cost'] ) ) {
			$update['cost'] = (float) $attributes['cost'];
		}

		MessageLog::update( $message_id, $update );

		/**
		 * Fires after a message has been accepted by TextTango.
		 *
		 * @param int    $message_id Log row id.
		 * @param string $uuid       TextTango message UUID.
		 * @param string $recipient  E.164 recipient.
		 */
		do_action( 'texttango_after_send', $message_id, $uuid, $recipient );

		return array(
			'message_id' => $message_id,
			'status'     => MessageLog::STATUS_SENT,
			'uuid'       => $uuid,
			'sandboxed'  => false,
		);
	}

	/**
	 * Write a failure to the log and return it.
	 *
	 * A timed-out send gets its own status rather than `failed`: the platform may
	 * have accepted it, and we hold no UUID to check with, so it must never be
	 * retried automatically (spec §2 — there is no idempotency key).
	 *
	 * @param int      $message_id Log row id.
	 * @param WP_Error $error      What went wrong.
	 */
	private static function record_failure( int $message_id, WP_Error $error ): WP_Error {
		$is_timeout = 'texttango_timeout' === $error->get_error_code();
		$data       = $error->get_error_data();

		MessageLog::update(
			$message_id,
			array(
				'status'       => $is_timeout ? MessageLog::STATUS_TIMEOUT : MessageLog::STATUS_FAILED,
				'error_code'   => is_array( $data ) && ! empty( $data['api_code'] )
					? (string) $data['api_code']
					: (string) $error->get_error_code(),
				'error_reason' => $error->get_error_message(),
			)
		);

		/**
		 * Fires when a send fails.
		 *
		 * The consent layer listens for this: a `RECIPIENT_SUPPRESSED` refusal is
		 * the only signal this site ever gets that someone opted out, since the
		 * platform exposes no inbound-SMS webhook and no SMS suppression endpoint.
		 *
		 * @param int      $message_id Log row id.
		 * @param WP_Error $error      What went wrong.
		 * @param string   $recipient  E.164 recipient.
		 */
		do_action( 'texttango_send_failed', $message_id, $error, (string) ( MessageLog::find( $message_id )['recipient'] ?? '' ) );

		return $error;
	}
}
