<?php
/**
 * Backing off when the API stops answering.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Sending;

use TextTango\Support\Clock;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Stops hammering an API that is not answering (spec §14, §19).
 *
 * When TextTango is unreachable, every send waits out its full timeout before
 * failing. On a busy site that is a queue of workers each holding a connection
 * open for fifteen seconds, and a checkout that hangs behind one. The outage is
 * the platform's; the site going slow with it is avoidable.
 *
 * ### Only connection failures count
 *
 * A `422` for a bad number, a `402` for no credit, a `429` for rate limiting —
 * none of these mean the API is down, and none of them open the breaker. They
 * are answers. Counting them would let one customer with an unusable number
 * stop messaging for the whole site.
 *
 * ### Failing fast is a feature, including for codes
 *
 * The breaker applies to everything, sign-in codes included, and that is
 * deliberate. Somebody at a login form is better served by "we could not send a
 * code, try again" in a moment than by a form that hangs for fifteen seconds and
 * then says the same thing. The cooldown is short for the same reason.
 *
 * There is no separate half-open state: once the cooldown passes the breaker
 * simply closes, and the next send is the probe. If it fails, the count is
 * already at the threshold and the breaker reopens immediately.
 */
final class CircuitBreaker {

	/**
	 * Option holding the current state. Never autoloaded.
	 */
	public const OPTION = 'texttango_circuit';

	/**
	 * Consecutive connection failures before the breaker opens.
	 */
	public const THRESHOLD = 5;

	/**
	 * How long it stays open, in seconds.
	 */
	public const COOLDOWN = 120;

	/**
	 * Error codes that mean the API could not be reached at all.
	 *
	 * @var string[]
	 */
	private const CONNECTION_FAILURES = array(
		'texttango_request_failed',
		'texttango_timeout',
		'texttango_server_error',
		'texttango_invalid_response',
	);

	/**
	 * Whether an error says the API is unreachable rather than unhappy.
	 *
	 * Pure, and the distinction the whole class rests on.
	 *
	 * @param string $code Error code from the client.
	 */
	public static function is_connection_failure( string $code ): bool {
		return in_array( $code, self::CONNECTION_FAILURES, true );
	}

	/**
	 * Whether the breaker is open right now.
	 */
	public static function is_open(): bool {
		$state = self::state();

		return $state['open_until'] > Clock::now();
	}

	/**
	 * How long until it closes, in seconds. Zero when it is already closed.
	 */
	public static function opens_in(): int {
		return max( 0, self::state()['open_until'] - Clock::now() );
	}

	/**
	 * Note that a request failed.
	 *
	 * @param WP_Error $error What went wrong.
	 */
	public static function record_failure( WP_Error $error ): void {
		if ( ! self::is_connection_failure( (string) $error->get_error_code() ) ) {
			// An answer, not an outage. It also does not reset the count: a mix of
			// bad numbers and timeouts is still an outage underneath.
			return;
		}

		$state              = self::state();
		$state['failures']  = $state['failures'] + 1;
		$state['last_code'] = (string) $error->get_error_code();

		if ( $state['failures'] >= self::THRESHOLD ) {
			$state['open_until'] = Clock::now() + self::COOLDOWN;
		}

		self::save( $state );
	}

	/**
	 * Note that a request succeeded.
	 *
	 * Resets everything: one good response is enough to say the outage is over,
	 * because the next failure will simply count up again.
	 */
	public static function record_success(): void {
		if ( 0 === self::state()['failures'] && ! self::is_open() ) {
			// Nothing stored and nothing to clear. Saving on every successful send
			// would write to the options table once per message.
			return;
		}

		delete_option( self::OPTION );
	}

	/**
	 * The error returned while the breaker is open.
	 */
	public static function error(): WP_Error {
		return new WP_Error(
			'texttango_circuit_open',
			__( 'TextTango is not responding, so sending has paused briefly rather than waiting on every message. It will try again shortly.', 'texttango' ),
			array( 'retry_after' => self::opens_in() )
		);
	}

	/**
	 * Forget everything. For the admin "try again now" action, and for tests.
	 */
	public static function reset(): void {
		delete_option( self::OPTION );
	}

	/**
	 * Current state, with every key present.
	 *
	 * @return array{failures: int, open_until: int, last_code: string}
	 */
	public static function state(): array {
		$stored = get_option( self::OPTION, array() );
		$stored = is_array( $stored ) ? $stored : array();

		return array(
			'failures'   => isset( $stored['failures'] ) ? (int) $stored['failures'] : 0,
			'open_until' => isset( $stored['open_until'] ) ? (int) $stored['open_until'] : 0,
			'last_code'  => isset( $stored['last_code'] ) ? (string) $stored['last_code'] : '',
		);
	}

	/**
	 * Persist state.
	 *
	 * @param array<string, mixed> $state State to store.
	 */
	private static function save( array $state ): void {
		update_option( self::OPTION, $state, false );
	}
}
