<?php
/**
 * Applies delivery receipts to the message log.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Webhook;

use TextTango\Api\PhoneNumber;
use TextTango\Messages\MessageLog;
use TextTango\Support\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Turns a `message.status_updated` payload into a log update and an order note
 * (spec §9, §19).
 *
 * Receipts arrive over an unreliable channel. TextTango retries three times, so
 * the same receipt can land twice; retries can also overtake each other, so a
 * `sent` can arrive after the `delivered` that followed it. Both are ordinary,
 * and neither may corrupt the record — so a status only ever moves forward, and
 * applying the same receipt twice changes nothing.
 */
final class Handler {

	/**
	 * The event this plugin acts on.
	 */
	public const EVENT_STATUS_UPDATED = 'message.status_updated';

	/**
	 * The synthetic event `POST /v2/webhooks/test` sends.
	 *
	 * It carries no message, so there is nothing to apply — but the fact that it
	 * arrived and verified is itself the thing worth recording, since it is the
	 * only proof a merchant can get that the loop closes.
	 */
	public const EVENT_TEST = 'webhook.test';

	/**
	 * How far a status can progress, so a late receipt cannot walk it backwards.
	 *
	 * @var array<string, int>
	 */
	private const PROGRESSION = array(
		MessageLog::STATUS_QUEUED    => 0,
		MessageLog::STATUS_SENDING   => 1,
		MessageLog::STATUS_SENT      => 2,
		MessageLog::STATUS_TIMEOUT   => 2,
		MessageLog::STATUS_DELIVERED => 3,
		MessageLog::STATUS_FAILED    => 3,
		MessageLog::STATUS_SKIPPED   => 3,
		MessageLog::STATUS_SANDBOX   => 3,
	);

	/**
	 * Apply a decoded payload.
	 *
	 * @param array<string, mixed> $payload Decoded webhook body.
	 * @return array{handled: bool, reason: string, message_id: int}
	 */
	public static function handle( array $payload ): array {
		$event = (string) ( $payload['event'] ?? '' );

		/*
		 * Anything reaching here came through the signed route, so arrival is
		 * recorded here rather than in apply() — which the reconciler also calls
		 * with a status it *polled* for. Counting a polled status as proof that
		 * receipts arrive would assert exactly the thing polling exists to work
		 * around, and would show a merchant "verified" for an endpoint nothing has
		 * ever reached.
		 */
		WebhookState::record_arrival( self::EVENT_TEST === $event ? 'test' : 'receipt' );

		if ( self::EVENT_TEST === $event ) {
			return self::result( true, 'test_received', 0 );
		}

		if ( self::EVENT_STATUS_UPDATED !== $event ) {
			// Campaign events and anything added later are acknowledged rather than
			// errored, so TextTango does not retry something we simply ignore.
			return self::result( false, 'ignored_event', 0 );
		}

		$uuid = (string) ( $payload['message_id'] ?? $payload['id'] ?? '' );

		if ( '' === $uuid ) {
			return self::result( false, 'missing_message_id', 0 );
		}

		$row = MessageLog::find_by_uuid( $uuid );

		if ( null === $row ) {
			// Receipts are origin-bound to the token that sent the message (spec §9),
			// so this is normally a message this site did not send — another install
			// sharing the token, or a row already purged.
			return self::result( false, 'unknown_message', 0 );
		}

		return self::apply( (int) $row['id'], $row, $payload );
	}

	/**
	 * Apply a status to a known row.
	 *
	 * Shared by the webhook route and the reconciler, so a status pulled from the
	 * API is treated exactly like one that was pushed.
	 *
	 * @param int                  $message_id Log row id.
	 * @param array<string, mixed> $row        Current row.
	 * @param array<string, mixed> $payload    Receipt or polled status.
	 * @return array{handled: bool, reason: string, message_id: int}
	 */
	public static function apply( int $message_id, array $row, array $payload ): array {
		$decision = self::decide( (string) $row['status'], (string) ( $payload['status'] ?? '' ) );

		if ( ! $decision['apply'] ) {
			return self::result( false, $decision['reason'], $message_id );
		}

		$status = $decision['status'];
		$update = array( 'status' => $status );

		$reason = self::failure_reason( $payload );

		if ( MessageLog::STATUS_FAILED === $status && '' !== $reason ) {
			$update['error_reason'] = $reason;
		}

		foreach ( array( 'cost', 'credits_used' ) as $key ) {
			if ( isset( $payload[ $key ] ) && is_numeric( $payload[ $key ] ) ) {
				$update['cost'] = (float) $payload[ $key ];
				break;
			}
		}

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

		self::annotate_order( $row, $status, $reason );

		/**
		 * Fires after a delivery receipt has been applied.
		 *
		 * @param int                  $message_id Log row id.
		 * @param string               $status     New status.
		 * @param array<string, mixed> $payload    Receipt payload.
		 */
		do_action( 'texttango_webhook_received', $message_id, $status, $payload );

		return self::result( true, $status, $message_id );
	}

	/**
	 * Decide what a receipt should do to a row.
	 *
	 * Pure, so the rules that make repeated and reordered receipts harmless can
	 * be reasoned about — and tested — without a database.
	 *
	 * @param string $current  Status already recorded.
	 * @param string $incoming Status from the receipt, in TextTango's vocabulary.
	 * @return array{apply: bool, status: string, reason: string}
	 */
	public static function decide( string $current, string $incoming ): array {
		$status = self::map_status( $incoming );

		if ( '' === $status ) {
			return self::decision( false, '', 'unknown_status' );
		}

		if ( $status === $current ) {
			// The same receipt again. Nothing to do, and nothing to complain about.
			return self::decision( false, $status, 'already_applied' );
		}

		/*
		 * A verdict is final. Delivered and failed are both endings, so comparing
		 * how far along they are says nothing useful — and letting a late `failed`
		 * land on a message the handset already confirmed would turn a successful
		 * delivery into a reported failure, which is worse than losing the receipt.
		 */
		if ( in_array( $current, MessageLog::TERMINAL_STATUSES, true ) ) {
			return self::decision( false, $status, 'already_final' );
		}

		if ( self::rank( $status ) < self::rank( $current ) ) {
			// A receipt that overtook another on the way here. Sent must not walk a
			// message back to queued because a retry arrived late.
			return self::decision( false, $status, 'out_of_order' );
		}

		return self::decision( true, $status, $status );
	}

	/**
	 * Shape a decision.
	 *
	 * @param bool   $apply  Whether the row should change.
	 * @param string $status Status the receipt maps to.
	 * @param string $reason Machine-readable outcome.
	 * @return array{apply: bool, status: string, reason: string}
	 */
	private static function decision( bool $apply, string $status, string $reason ): array {
		return array(
			'apply'  => $apply,
			'status' => $status,
			'reason' => $reason,
		);
	}

	/**
	 * Translate a TextTango status into one of ours.
	 *
	 * Written tolerantly: an unrecognised status is reported rather than guessed
	 * at, but the common synonyms all land somewhere sensible.
	 *
	 * @param string $status Status from the payload.
	 */
	public static function map_status( string $status ): string {
		$status = strtolower( trim( $status ) );

		$map = array(
			'delivered'   => MessageLog::STATUS_DELIVERED,
			'delivrd'     => MessageLog::STATUS_DELIVERED,
			'failed'      => MessageLog::STATUS_FAILED,
			'undelivered' => MessageLog::STATUS_FAILED,
			'undeliv'     => MessageLog::STATUS_FAILED,
			'rejected'    => MessageLog::STATUS_FAILED,
			'expired'     => MessageLog::STATUS_FAILED,
			'sent'        => MessageLog::STATUS_SENT,
			'dispatched'  => MessageLog::STATUS_SENT,
			'submitted'   => MessageLog::STATUS_SENT,
			'accepted'    => MessageLog::STATUS_SENT,
			'queued'      => MessageLog::STATUS_QUEUED,
			'pending'     => MessageLog::STATUS_QUEUED,
		);

		return $map[ $status ] ?? '';
	}

	/**
	 * How far along a status is.
	 *
	 * @param string $status One of our statuses.
	 */
	private static function rank( string $status ): int {
		return self::PROGRESSION[ $status ] ?? 0;
	}

	/**
	 * The reason a message failed, if the payload gave one.
	 *
	 * @param array<string, mixed> $payload Receipt payload.
	 */
	private static function failure_reason( array $payload ): string {
		foreach ( array( 'error_reason', 'reason', 'error', 'failure_reason', 'detail' ) as $key ) {
			if ( ! empty( $payload[ $key ] ) && is_scalar( $payload[ $key ] ) ) {
				return sanitize_text_field( (string) $payload[ $key ] );
			}
		}

		return '';
	}

	/**
	 * Note a delivery outcome on the related order.
	 *
	 * Only terminal outcomes are noted. Writing a line for every intermediate
	 * status would bury the order history in noise.
	 *
	 * @param array<string, mixed> $row    Log row.
	 * @param string               $status New status.
	 * @param string               $reason Failure reason, if any.
	 */
	private static function annotate_order( array $row, string $status, string $reason ): void {
		$order_id = (int) ( $row['order_id'] ?? 0 );

		if ( $order_id <= 0 || ! Settings::get( 'order_notes_enabled', true ) ) {
			return;
		}

		if ( ! in_array( $status, array( MessageLog::STATUS_DELIVERED, MessageLog::STATUS_FAILED ), true ) ) {
			return;
		}

		if ( ! function_exists( 'wc_get_order' ) ) {
			return;
		}

		$order = wc_get_order( $order_id );

		if ( ! $order instanceof \WC_Order ) {
			return;
		}

		$masked = PhoneNumber::mask( (string) $row['recipient'] );

		if ( MessageLog::STATUS_DELIVERED === $status ) {
			$order->add_order_note(
				sprintf(
					/* translators: %s: masked recipient number. */
					__( 'TextTango: SMS delivered to %s.', 'texttango' ),
					$masked
				)
			);

			return;
		}

		$order->add_order_note(
			'' !== $reason
				? sprintf(
					/* translators: 1: masked recipient number, 2: reason the message failed. */
					__( 'TextTango: SMS to %1$s failed - %2$s', 'texttango' ),
					$masked,
					$reason
				)
				: sprintf(
					/* translators: %s: masked recipient number. */
					__( 'TextTango: SMS to %s failed.', 'texttango' ),
					$masked
				)
		);
	}

	/**
	 * Shape a result for the caller.
	 *
	 * @param bool   $handled    Whether the row changed.
	 * @param string $reason     Machine-readable outcome.
	 * @param int    $message_id Log row id.
	 * @return array{handled: bool, reason: string, message_id: int}
	 */
	private static function result( bool $handled, string $reason, int $message_id ): array {
		return array(
			'handled'    => $handled,
			'reason'     => $reason,
			'message_id' => $message_id,
		);
	}
}
