<?php
/**
 * Telling the merchant when sending has stopped working.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Sending;

use TextTango\Support\Clock;
use TextTango\Support\Settings;
use TextTango\WooCommerce\Templates;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * An email when the plugin stops being able to send (spec §15).
 *
 * ### What counts as broken, and what does not
 *
 * A message that fails is not a fault. A rejected number, a customer with no
 * phone, somebody on the opt-out list — those are the plugin working, and
 * emailing about them would train the merchant to ignore the emails, which is
 * the only outcome worse than not sending them.
 *
 * Broken means **nothing can send until a person does something**:
 *
 * - the token has been revoked or is wrong, so every call is refused;
 * - the account is out of credit, so every call is refused;
 * - the circuit breaker has opened, so the API is not answering at all.
 *
 * Each of those has an action attached, which is why each is worth an email.
 *
 * ### Why the cooldown is generous
 *
 * A broken token breaks every message. On a busy store that is hundreds of
 * failures an hour, and an email each would be a mailbox nobody can use — the
 * exact failure mode that makes people filter the sender and then miss the one
 * that mattered. One email per reason per {@see self::COOLDOWN} says the same
 * thing once. The admin notice and Site Health carry the rest.
 */
final class HealthAlert {

	/**
	 * Option holding when each reason was last reported.
	 */
	public const OPTION = 'texttango_health_alerted';

	/**
	 * The shortest time between emails about the same fault.
	 */
	public const COOLDOWN = 6 * HOUR_IN_SECONDS;

	/**
	 * The token is refused.
	 */
	public const REASON_AUTH = 'auth';

	/**
	 * The account has no credit.
	 */
	public const REASON_CREDIT = 'credit';

	/**
	 * The API is not answering.
	 */
	public const REASON_UNREACHABLE = 'unreachable';

	/**
	 * Error codes that mean the token will not work again by itself.
	 *
	 * These are `Client`'s names for 401 and 403, plus the case where no token
	 * is configured at all. A test holds them against that class, because a
	 * renamed code here fails silently — the merchant simply stops being told.
	 *
	 * @var string[]
	 */
	public const AUTH_CODES = array(
		'texttango_unauthorized',
		'texttango_forbidden',
		'texttango_not_connected',
	);

	/**
	 * Error codes that mean the account cannot pay for the message.
	 *
	 * @var string[]
	 */
	public const CREDIT_CODES = array(
		'texttango_insufficient_balance',
	);

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

	/**
	 * Whether the merchant asked to be told.
	 */
	public static function is_enabled(): bool {
		return (bool) Settings::get( 'health_alert_enabled', false );
	}

	/**
	 * A send failed. Decide whether it means the plugin is broken.
	 *
	 * @param int            $message_id Message log id. Unused.
	 * @param WP_Error|mixed $error      What went wrong.
	 */
	public function on_send_failed( $message_id, $error ): void {
		unset( $message_id );

		if ( ! $error instanceof WP_Error || ! self::is_enabled() ) {
			return;
		}

		$reason = self::classify( (string) $error->get_error_code(), CircuitBreaker::is_open() );

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

		self::report( $reason, (string) $error->get_error_message() );
	}

	/**
	 * Which fault, if any, a failure represents.
	 *
	 * Pure. The list of codes that mean "a person must do something" is the
	 * whole judgement here, so it reads as one function rather than as a
	 * condition buried in a handler.
	 *
	 * @param string $code         Error code from the failed send.
	 * @param bool   $circuit_open Whether the breaker is open.
	 * @return string One of the REASON_* constants, or '' when nothing is broken.
	 */
	public static function classify( string $code, bool $circuit_open ): string {
		if ( in_array( $code, self::AUTH_CODES, true ) ) {
			return self::REASON_AUTH;
		}

		if ( in_array( $code, self::CREDIT_CODES, true ) ) {
			return self::REASON_CREDIT;
		}

		/*
		 * Checked last, and only once the breaker has actually opened. A single
		 * timeout is not an outage — the breaker exists precisely to decide when
		 * a run of them becomes one, and this defers to that judgement rather
		 * than making a second, weaker one.
		 */
		if ( $circuit_open && CircuitBreaker::is_connection_failure( $code ) ) {
			return self::REASON_UNREACHABLE;
		}

		return '';
	}

	/**
	 * Email the fault, unless it has been emailed recently.
	 *
	 * @param string $reason One of the REASON_* constants.
	 * @param string $detail The underlying message, for the body.
	 */
	public static function report( string $reason, string $detail = '' ): void {
		$now = Clock::now();

		if ( ! self::due( $reason, $now ) ) {
			return;
		}

		// Stamped before sending. A mail server that hangs must cost the merchant
		// one missed email, not one email per failed message for as long as it
		// keeps hanging.
		self::stamp( $reason, $now );

		$to = self::address();

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

		wp_mail( $to, self::subject( $reason ), self::body( $reason, $detail ) );

		/**
		 * Fires after a fault has been reported to the merchant.
		 *
		 * @since 0.6.0
		 *
		 * @param string $reason One of the HealthAlert REASON_* constants.
		 * @param string $to     Address it went to.
		 */
		do_action( 'texttango_health_alert_sent', $reason, $to );
	}

	/**
	 * Whether this fault may be reported again yet.
	 *
	 * @param string $reason One of the REASON_* constants.
	 * @param int    $now    Current timestamp.
	 */
	public static function due( string $reason, int $now ): bool {
		$stamps = get_option( self::OPTION, array() );
		$last   = is_array( $stamps ) ? (int) ( $stamps[ $reason ] ?? 0 ) : 0;

		return self::elapsed( $last, $now );
	}

	/**
	 * The cooldown rule itself.
	 *
	 * Pure, so a clock that has gone backwards is a decided case rather than an
	 * accident: a stamp in the future holds the alert rather than releasing it.
	 *
	 * @param int $last When this fault was last reported. 0 means never.
	 * @param int $now  Current timestamp.
	 */
	public static function elapsed( int $last, int $now ): bool {
		if ( $last <= 0 ) {
			return true;
		}

		return $now - $last >= self::COOLDOWN;
	}

	/**
	 * Record that a fault has been reported.
	 *
	 * @param string $reason One of the REASON_* constants.
	 * @param int    $now    Current timestamp.
	 */
	private static function stamp( string $reason, int $now ): void {
		$stamps = get_option( self::OPTION, array() );
		$stamps = is_array( $stamps ) ? $stamps : array();

		$stamps[ $reason ] = $now;

		update_option( self::OPTION, $stamps, false );
	}

	/**
	 * Forget every stamp, so the next fault is reported immediately.
	 *
	 * Called when the plugin recovers, because the next time this breaks it is a
	 * new fault rather than a continuation of the old one.
	 */
	public static function clear(): void {
		delete_option( self::OPTION );
	}

	/**
	 * Where the email goes.
	 */
	public static function address(): string {
		$to = (string) Settings::get( 'health_alert_email', '' );

		if ( '' === trim( $to ) ) {
			$to = (string) get_option( 'admin_email', '' );
		}

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

	/**
	 * Subject line for a fault.
	 *
	 * @param string $reason One of the REASON_* constants.
	 */
	public static function subject( string $reason ): string {
		$store = Templates::store_name();

		if ( self::REASON_CREDIT === $reason ) {
			/* translators: %s: store name. */
			return sprintf( __( '[%s] SMS has stopped — no credit', 'texttango' ), $store );
		}

		if ( self::REASON_AUTH === $reason ) {
			/* translators: %s: store name. */
			return sprintf( __( '[%s] SMS has stopped — TextTango is refusing the token', 'texttango' ), $store );
		}

		/* translators: %s: store name. */
		return sprintf( __( '[%s] SMS has stopped — TextTango is not answering', 'texttango' ), $store );
	}

	/**
	 * What the email says.
	 *
	 * Written to be read on a phone at an inconvenient hour: what stopped, what
	 * it means for customers, and the one thing to do about it.
	 *
	 * @param string $reason One of the REASON_* constants.
	 * @param string $detail The underlying error message.
	 */
	public static function body( string $reason, string $detail = '' ): string {
		$lines = array( self::explain( $reason ), '', __( 'Messages are not being delivered until this is fixed. Nothing has been lost — anything queued will be retried.', 'texttango' ) );

		if ( '' !== trim( $detail ) ) {
			$lines[] = '';
			/* translators: %s: the error message TextTango returned. */
			$lines[] = sprintf( __( 'TextTango said: %s', 'texttango' ), $detail );
		}

		$lines[] = '';
		$lines[] = admin_url( 'admin.php?page=texttango-settings' );

		return implode( "\n", $lines );
	}

	/**
	 * The fault, and what to do about it.
	 *
	 * @param string $reason One of the REASON_* constants.
	 */
	public static function explain( string $reason ): string {
		if ( self::REASON_CREDIT === $reason ) {
			return __( 'Your TextTango account has run out of credit, so no SMS can be sent. Top up your balance and sending resumes on its own.', 'texttango' );
		}

		if ( self::REASON_AUTH === $reason ) {
			return __( 'TextTango is refusing the API token this site uses. It has probably been revoked or regenerated. Create a new token and paste it into TextTango → Settings.', 'texttango' );
		}

		return __( 'This site cannot reach TextTango. Sending has paused itself and will try again shortly. If this does not clear on its own, check whether outbound requests from your server are being blocked.', 'texttango' );
	}
}
