<?php
/**
 * Background delivery of queued messages.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Sending;

use TextTango\Messages\MessageLog;

defined( 'ABSPATH' ) || exit;

/**
 * Hands queued messages to Action Scheduler (spec §3, §12).
 *
 * No send happens on the request thread. A customer completing checkout must
 * never wait on TextTango, and a provider having a slow minute must never turn
 * into a failed order.
 */
final class Queue {

	/**
	 * Action Scheduler hook that delivers one message.
	 */
	public const HOOK = 'texttango_send_message';

	/**
	 * Group every scheduled action belongs to, so they can be found and cancelled.
	 */
	public const GROUP = 'texttango';

	/**
	 * Total delivery attempts before a message is left as failed.
	 */
	public const MAX_ATTEMPTS = 4;

	/**
	 * Seconds to wait before each retry.
	 *
	 * Mirrors the backoff TextTango uses for its own webhook retries.
	 *
	 * @var int[]
	 */
	private const BACKOFF = array( 60, 300, 900 );

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

	/**
	 * Whether Action Scheduler is available.
	 *
	 * It ships with this plugin, so this only fails if the autoloader was
	 * tampered with — but a missing queue must degrade, not fatal.
	 */
	public static function is_available(): bool {
		return function_exists( 'as_enqueue_async_action' )
			&& function_exists( 'as_schedule_single_action' );
	}

	/**
	 * Schedule a message for delivery.
	 *
	 * @param int $message_id Log row id.
	 * @param int $delay      Seconds to wait; 0 runs as soon as the queue is drained.
	 * @return bool Whether the message was scheduled.
	 */
	public static function enqueue( int $message_id, int $delay = 0 ): bool {
		if ( ! self::is_available() ) {
			// No queue: deliver inline rather than silently dropping the message.
			Sender::deliver( $message_id );

			return false;
		}

		$args = array( 'message_id' => $message_id );

		if ( $delay > 0 ) {
			as_schedule_single_action( time() + $delay, self::HOOK, $args, self::GROUP );

			return true;
		}

		as_enqueue_async_action( self::HOOK, $args, self::GROUP );

		return true;
	}

	/**
	 * Action Scheduler callback.
	 *
	 * @param mixed $message_id Log row id, as passed through the queue.
	 */
	public static function run( $message_id ): void {
		Sender::deliver( (int) $message_id );
	}

	/**
	 * How long to wait before a given attempt, or null when out of attempts.
	 *
	 * @param int $attempts Attempts already made.
	 */
	public static function backoff_for( int $attempts ): ?int {
		$index = max( 0, $attempts - 1 );

		if ( $attempts >= self::MAX_ATTEMPTS || ! isset( self::BACKOFF[ $index ] ) ) {
			return null;
		}

		return self::BACKOFF[ $index ];
	}

	/**
	 * Cancel any pending delivery for a message.
	 *
	 * @param int $message_id Log row id.
	 */
	public static function cancel( int $message_id ): void {
		if ( function_exists( 'as_unschedule_all_actions' ) ) {
			as_unschedule_all_actions( self::HOOK, array( array( 'message_id' => $message_id ) ), self::GROUP );
		}
	}

	/**
	 * Whether a failure is worth another attempt.
	 *
	 * A timeout is deliberately excluded. The send endpoint has no idempotency
	 * key (spec §2), so a request that timed out may already have been accepted
	 * and billed — retrying it could charge the merchant twice for one message.
	 *
	 * @param string $error_code Error code from the client.
	 */
	public static function is_retryable( string $error_code ): bool {
		return in_array(
			$error_code,
			array(
				'texttango_rate_limited',
				'texttango_server_error',
				'texttango_transport_error',
				/*
				 * The one failure where retrying is unambiguously safe. Everywhere
				 * else the worry is that the platform may have accepted a message
				 * before the connection broke, so a retry could bill and deliver it
				 * twice. An open circuit means the request was never made at all —
				 * nothing left this site — so the message is not merely safe to
				 * retry, it is lost if we do not.
				 */
				'texttango_circuit_open',
			),
			true
		);
	}

	/**
	 * Statuses a worker should refuse to act on, because the message is done.
	 *
	 * The queue can deliver the same action twice — Action Scheduler guarantees
	 * at-least-once, not exactly-once — so the worker has to be idempotent.
	 *
	 * @param string $status Current row status.
	 */
	public static function is_settled( string $status ): bool {
		return in_array(
			$status,
			array_merge(
				MessageLog::TERMINAL_STATUSES,
				array( MessageLog::STATUS_SENT, MessageLog::STATUS_TIMEOUT )
			),
			true
		);
	}
}
