<?php
/**
 * Native Site Health tests.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Health;

use TextTango\Admin\Ajax;
use TextTango\Api\Client;
use TextTango\Install\Deactivator;
use TextTango\Messages\MessageLog;
use TextTango\Support\Sandbox;
use TextTango\Support\Settings;
use TextTango\Webhook\Registration;
use TextTango\Webhook\Route;
use TextTango\Webhook\WebhookState;

defined( 'ABSPATH' ) || exit;

/**
 * Puts this plugin's health where WordPress already tells people to look (spec §20).
 *
 * Everything here was previously only discoverable by noticing that something had
 * silently stopped: receipts not arriving, a queue not draining, a balance run
 * down mid-campaign. Site Health is where a merchant or their agency already goes
 * when a site feels wrong, so the checks belong there rather than behind a tab in
 * this plugin that nobody visits until they already suspect it.
 *
 * ### On claiming things
 *
 * There is no whoami endpoint on the v2 API, so a token's abilities cannot be
 * listed — only exercised. `account:read` is proven by the wallet call and
 * `webhooks:read` by reading the configuration, but `sms:send` cannot be proven
 * without spending a credit, and `webhooks:write` only by actually writing. So
 * these tests report what they have genuinely established and say plainly that
 * the rest is untested. A green tick standing for an unverified claim is worse
 * than an honest gap, because it is the tick somebody trusts at 2am.
 */
final class SiteHealth {

	/**
	 * Prefix for result caches.
	 */
	private const CACHE_PREFIX = 'texttango_health_';

	/**
	 * How long a probe result is reused, in seconds.
	 *
	 * Site Health re-runs on every page view, and each network test costs an API
	 * call against a rate limit shared with real sends.
	 */
	private const CACHE_TTL = 5 * MINUTE_IN_SECONDS;

	/**
	 * Balance below which the test warns, in account currency.
	 */
	private const LOW_BALANCE = 20.0;

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_filter( 'site_status_tests', array( $this, 'register_tests' ) );
		add_filter( 'debug_information', array( $this, 'register_debug_information' ) );
	}

	/**
	 * Declare the tests.
	 *
	 * Network probes are `async` so the Site Health screen is not held up by an
	 * API that may be slow or unreachable — which is precisely the case these
	 * tests exist to detect.
	 *
	 * @param array<string, mixed> $tests Registered tests.
	 * @return array<string, mixed>
	 */
	public function register_tests( $tests ) {
		if ( ! is_array( $tests ) ) {
			return $tests;
		}

		$tests['direct']['texttango_queue'] = array(
			'label' => __( 'TextTango background jobs', 'texttango' ),
			'test'  => array( $this, 'test_queue' ),
		);

		$tests['direct']['texttango_receipts'] = array(
			'label' => __( 'TextTango delivery receipts', 'texttango' ),
			'test'  => array( $this, 'test_receipts' ),
		);

		$tests['async']['texttango_connection'] = array(
			'label'             => __( 'TextTango connection', 'texttango' ),
			'test'              => 'texttango_connection',
			'has_rest'          => false,
			'async_direct_test' => array( $this, 'test_connection' ),
		);

		$tests['async']['texttango_route'] = array(
			'label'             => __( 'TextTango receipt endpoint', 'texttango' ),
			'test'              => 'texttango_route',
			'has_rest'          => false,
			'async_direct_test' => array( $this, 'test_route' ),
		);

		$tests['async']['texttango_sender'] = array(
			'label'             => __( 'TextTango sender ID', 'texttango' ),
			'test'              => 'texttango_sender',
			'has_rest'          => false,
			'async_direct_test' => array( $this, 'test_sender' ),
		);

		return $tests;
	}

	/**
	 * Can this site talk to TextTango, and is the balance worth having.
	 *
	 * @return array<string, mixed>
	 */
	public function test_connection(): array {
		if ( ! Settings::secret_exists( Settings::OPTION_API_KEY ) ) {
			return $this->result(
				'critical',
				__( 'TextTango is not connected', 'texttango' ),
				__( 'No API token is stored, so nothing can be sent. Paste a token on the TextTango settings screen.', 'texttango' )
			);
		}

		if ( Settings::secret_is_unreadable( Settings::OPTION_API_KEY ) ) {
			return $this->result(
				'critical',
				__( 'TextTango credentials cannot be read', 'texttango' ),
				__( 'A token is stored but cannot be decrypted, which normally means this site\'s security salts were rotated. Paste the token again.', 'texttango' )
			);
		}

		$wallet = $this->cached(
			'wallet',
			static function () {
				$client = Client::from_settings();

				return is_wp_error( $client ) ? $client : $client->wallet();
			}
		);

		if ( is_wp_error( $wallet ) ) {
			return $this->result(
				'critical',
				__( 'TextTango is not answering', 'texttango' ),
				sprintf(
					/* translators: %s: the error reported by the API. */
					__( 'The stored token could not be used: %s Messages will not send until this is resolved.', 'texttango' ),
					esc_html( $wallet->get_error_message() )
				)
			);
		}

		$balance = Ajax::parse_wallet( (array) $wallet );

		if ( $balance['main'] <= 0 ) {
			return $this->result(
				'critical',
				__( 'TextTango balance is empty', 'texttango' ),
				__( 'The token works, but there is nothing left to send with. Top up the account to resume messaging.', 'texttango' )
			);
		}

		if ( $balance['main'] < self::LOW_BALANCE ) {
			return $this->result(
				'recommended',
				__( 'TextTango balance is low', 'texttango' ),
				sprintf(
					/* translators: %s: formatted balance, e.g. "GHS 12.40". */
					__( 'Connected, with %s remaining. Messages stop when it runs out, sign-in codes included.', 'texttango' ),
					esc_html( $balance['formatted'] )
				)
			);
		}

		return $this->result(
			'good',
			__( 'TextTango is connected', 'texttango' ),
			sprintf(
				/* translators: %s: formatted balance. */
				__( 'The token works and the balance is %s. This proves the token can read the account; whether it may send, or manage webhooks, is only proven by doing those things.', 'texttango' ),
				esc_html( $balance['formatted'] )
			)
		);
	}

	/**
	 * Is the receipt endpoint registered, secret-protected, and proven to work.
	 *
	 * @return array<string, mixed>
	 */
	public function test_receipts(): array {
		if ( ! Settings::secret_exists( Settings::OPTION_WEBHOOK_SECRET ) ) {
			return $this->result(
				'recommended',
				__( 'TextTango delivery receipts are not set up', 'texttango' ),
				__( 'No signing secret is stored, so every inbound receipt is rejected and the message log will never move past "sent". Messages still send.', 'texttango' )
			);
		}

		if ( ! WebhookState::is_verified() ) {
			return $this->result(
				'recommended',
				__( 'TextTango delivery receipts are unproven', 'texttango' ),
				__( 'A signing secret is stored, but nothing signed by TextTango has ever arrived here. Use "Send test event" on the Connection tab to find out whether the loop closes.', 'texttango' )
			);
		}

		return $this->result(
			'good',
			__( 'TextTango delivery receipts are working', 'texttango' ),
			sprintf(
				/* translators: %s: how long ago, e.g. "2 hours". */
				__( 'A signed request from TextTango arrived here and verified %s ago.', 'texttango' ),
				esc_html( human_time_diff( WebhookState::last_arrival() ) )
			)
		);
	}

	/**
	 * Is the REST route reachable from outside.
	 *
	 * The success condition is a **401**. An unsigned request must be refused, so
	 * being refused proves the route exists and is guarding itself; a 404 means
	 * REST or permalinks are broken and no receipt could ever land, however
	 * correct the rest of the configuration is.
	 *
	 * @return array<string, mixed>
	 */
	public function test_route(): array {
		$code = $this->cached(
			'route',
			static function () {
				$url  = Route::url();
				$host = (string) wp_parse_url( $url, PHP_URL_HOST );

				$response = wp_remote_post(
					$url,
					array(
						'timeout'   => 10,
						'blocking'  => true,
						/*
						 * Local development sites run on self-signed certificates, so
						 * verifying would fail the check on every one of them and
						 * report a broken endpoint that is in fact fine. The same
						 * exemption the API client makes, for the same hosts.
						 */
						'sslverify' => ! Client::is_local_host( $host ),
						'body'      => wp_json_encode( array( 'event' => 'site-health.probe' ) ),
						'headers'   => array( 'Content-Type' => 'application/json' ),
					)
				);

				return is_wp_error( $response ) ? $response : (int) wp_remote_retrieve_response_code( $response );
			}
		);

		if ( is_wp_error( $code ) ) {
			return $this->result(
				'recommended',
				__( 'TextTango receipt endpoint could not be reached', 'texttango' ),
				sprintf(
					/* translators: %s: the loopback error. */
					__( 'This site could not call its own receipt URL: %s That may just be a loopback restriction on this host rather than a real problem, but TextTango has to reach it.', 'texttango' ),
					esc_html( $code->get_error_message() )
				)
			);
		}

		if ( 401 === $code ) {
			return $this->result(
				'good',
				__( 'TextTango receipt endpoint is reachable', 'texttango' ),
				__( 'The endpoint answered and correctly refused an unsigned request, which is exactly what it should do.', 'texttango' )
			);
		}

		if ( 404 === $code ) {
			return $this->result(
				'critical',
				__( 'TextTango receipt endpoint is missing', 'texttango' ),
				__( 'The URL returns 404, so no delivery receipt can ever arrive. The REST API is usually disabled, blocked by a security plugin, or permalinks need saving again.', 'texttango' )
			);
		}

		return $this->result(
			'recommended',
			__( 'TextTango receipt endpoint answered unexpectedly', 'texttango' ),
			sprintf(
				/* translators: %d: HTTP status code. */
				__( 'An unsigned request returned %d rather than the expected 401. Something in front of WordPress may be intercepting the route.', 'texttango' ),
				(int) $code
			)
		);
	}

	/**
	 * Is a sender ID set, and is it one the account may actually use.
	 *
	 * @return array<string, mixed>
	 */
	public function test_sender(): array {
		$sender = trim( (string) Settings::get( 'sender_id', '' ) );

		if ( '' === $sender ) {
			return $this->result(
				'recommended',
				__( 'No TextTango sender ID is set', 'texttango' ),
				__( 'Messages will be sent with whatever default the account applies, which may not be the store\'s name. Set one on the Connection tab.', 'texttango' )
			);
		}

		$response = $this->cached(
			'senders',
			static function () {
				$client = Client::from_settings();

				return is_wp_error( $client ) ? $client : $client->sender_ids();
			}
		);

		if ( is_wp_error( $response ) ) {
			return $this->result(
				'recommended',
				__( 'TextTango sender ID could not be checked', 'texttango' ),
				__( 'The approved sender IDs could not be read, so whether this one is usable is unknown. The connection test above will say why.', 'texttango' )
			);
		}

		$approved = Ajax::parse_sender_ids( (array) $response );

		if ( ! empty( $approved ) && ! in_array( $sender, $approved, true ) ) {
			return $this->result(
				'critical',
				__( 'TextTango sender ID is not approved', 'texttango' ),
				sprintf(
					/* translators: 1: configured sender ID, 2: comma-separated approved list. */
					__( '"%1$s" is not among the approved sender IDs on this account (%2$s), so sends using it will be rejected.', 'texttango' ),
					esc_html( $sender ),
					esc_html( implode( ', ', $approved ) )
				)
			);
		}

		return $this->result(
			'good',
			__( 'TextTango sender ID is approved', 'texttango' ),
			sprintf(
				/* translators: %s: the configured sender ID. */
				__( 'Messages are sent as "%s", which this account is approved to use.', 'texttango' ),
				esc_html( $sender )
			)
		);
	}

	/**
	 * Is the queue that everything asynchronous depends on actually running.
	 *
	 * @return array<string, mixed>
	 */
	public function test_queue(): array {
		if ( ! function_exists( 'as_next_scheduled_action' ) ) {
			return $this->result(
				'critical',
				__( 'TextTango background jobs cannot run', 'texttango' ),
				__( 'Action Scheduler is not loaded, so queued messages, contact syncing and the delivery self-heal will not run at all.', 'texttango' )
			);
		}

		foreach ( Deactivator::SCHEDULED_HOOKS as $hook ) {
			if ( false !== as_next_scheduled_action( $hook, null, 'texttango' ) ) {
				return $this->result(
					'good',
					__( 'TextTango background jobs are scheduled', 'texttango' ),
					__( 'Action Scheduler is running and this plugin has work queued with it.', 'texttango' )
				);
			}
		}

		return $this->result(
			'recommended',
			__( 'TextTango has no background jobs scheduled', 'texttango' ),
			__( 'Action Scheduler is available but nothing of this plugin\'s is queued. That is normal on a quiet site, and a problem if messages are being sent but never arrive — check Tools → Scheduled Actions.', 'texttango' )
		);
	}

	/**
	 * Facts worth having in a bug report, and nothing that would be a leak.
	 *
	 * @param array<string, mixed> $info Registered panels.
	 * @return array<string, mixed>
	 */
	public function register_debug_information( $info ) {
		if ( ! is_array( $info ) ) {
			return $info;
		}

		$state = WebhookState::all();

		$info['texttango'] = array(
			'label'       => __( 'TextTango', 'texttango' ),
			'description' => __( 'Configuration of the TextTango SMS plugin. Credentials are reported as present or absent only — no token, secret or customer number appears here, so this panel is safe to paste into a support request.', 'texttango' ),
			'fields'      => array(
				'version'         => array(
					'label' => __( 'Plugin version', 'texttango' ),
					'value' => TEXTTANGO_VERSION,
				),
				'api_base_url'    => array(
					'label' => __( 'API base URL', 'texttango' ),
					'value' => (string) Settings::get( 'api_base_url', '' ),
				),
				'api_token'       => array(
					'label' => __( 'API token', 'texttango' ),
					'value' => $this->secret_state( Settings::OPTION_API_KEY ),
				),
				'webhook_secret'  => array(
					'label' => __( 'Webhook signing secret', 'texttango' ),
					'value' => $this->secret_state( Settings::OPTION_WEBHOOK_SECRET ),
				),
				'receipt_url'     => array(
					'label' => __( 'Receipt URL', 'texttango' ),
					'value' => Route::url(),
				),
				'receipts_proven' => array(
					'label' => __( 'Receipts proven', 'texttango' ),
					'value' => WebhookState::is_verified()
						? sprintf(
							/* translators: %s: how long ago. */
							__( 'Yes, %s ago', 'texttango' ),
							human_time_diff( WebhookState::last_arrival() )
						)
						: __( 'Never', 'texttango' ),
				),
				'secret_rotated'  => array(
					'label' => __( 'Secret last rotated here', 'texttango' ),
					'value' => $state['rotated_at'] > 0
						? gmdate( 'Y-m-d H:i', (int) $state['rotated_at'] ) . ' UTC'
						: __( 'Never', 'texttango' ),
				),
				'sandbox'         => array(
					'label' => __( 'Sandbox', 'texttango' ),
					'value' => Sandbox::describe(),
				),
				'environment'     => array(
					'label' => __( 'Environment type', 'texttango' ),
					'value' => wp_get_environment_type(),
				),
				'sender_id'       => array(
					'label' => __( 'Sender ID', 'texttango' ),
					'value' => (string) Settings::get( 'sender_id', '' ),
				),
				'default_country' => array(
					'label' => __( 'Default country', 'texttango' ),
					'value' => (string) Settings::get( 'default_country', '' ),
				),
				'events_on'       => array(
					'label' => __( 'Notifications enabled', 'texttango' ),
					'value' => (string) count( array_filter( (array) Settings::get( 'notifications', array() ) ) ),
				),
				'messages'        => array(
					'label' => __( 'Messages logged', 'texttango' ),
					'value' => (string) MessageLog::count(),
				),
				'registration'    => array(
					'label' => __( 'Registered callback', 'texttango' ),
					'value' => '' !== $state['registered_url']
						? $state['registered_url']
						: __( 'Not registered from WordPress', 'texttango' ),
				),
				'field_owned'     => array(
					'label' => __( 'Callback field used', 'texttango' ),
					'value' => Registration::FIELD,
				),
			),
		);

		return $info;
	}

	/**
	 * Whether a secret is absent, stored, or stored but unreadable.
	 *
	 * @param string $option One of the Settings::OPTION_* secret constants.
	 */
	private function secret_state( string $option ): string {
		if ( ! Settings::secret_exists( $option ) ) {
			return __( 'Not set', 'texttango' );
		}

		return Settings::secret_is_unreadable( $option )
			? __( 'Stored, but cannot be decrypted', 'texttango' )
			: __( 'Stored', 'texttango' );
	}

	/**
	 * Run a probe, reusing a recent answer.
	 *
	 * Failures are cached for a fraction of the time successes are: an outage
	 * that has just cleared should show as cleared quickly, while a healthy site
	 * has no reason to keep asking.
	 *
	 * @param string   $key   Cache key suffix.
	 * @param callable $probe Returns the value or a WP_Error.
	 * @return mixed
	 */
	private function cached( string $key, callable $probe ) {
		$cached = get_transient( self::CACHE_PREFIX . $key );

		if ( false !== $cached ) {
			return $cached;
		}

		$value = $probe();

		set_transient(
			self::CACHE_PREFIX . $key,
			$value,
			is_wp_error( $value ) ? MINUTE_IN_SECONDS : self::CACHE_TTL
		);

		return $value;
	}

	/**
	 * Shape a result the way Site Health expects.
	 *
	 * @param string $status      One of 'good', 'recommended', 'critical'.
	 * @param string $label       Headline.
	 * @param string $description Explanation, as escaped HTML.
	 * @return array<string, mixed>
	 */
	private function result( string $status, string $label, string $description ): array {
		return array(
			'label'       => $label,
			'status'      => $status,
			'badge'       => array(
				'label' => __( 'TextTango', 'texttango' ),
				'color' => 'blue',
			),
			'description' => '<p>' . $description . '</p>',
			'actions'     => sprintf(
				'<p><a href="%1$s">%2$s</a></p>',
				esc_url( admin_url( 'admin.php?page=texttango' ) ),
				esc_html__( 'Open TextTango settings', 'texttango' )
			),
			'test'        => 'texttango',
		);
	}
}
