<?php
/**
 * Setting this site up as the receipt endpoint.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Webhook;

use TextTango\Api\Client;
use TextTango\Support\Clock;
use TextTango\Support\Crypto;
use TextTango\Support\Settings;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Registers, tests and re-keys the delivery-receipt endpoint (spec §9, §19, §20).
 *
 * Phase 1 left all of this to copy and paste: the merchant took a URL from the
 * settings screen, put it in the dashboard, found the `whsec_…` shown once when
 * the token was created, and pasted it back. Getting any step wrong produced no
 * error anywhere — receipts simply never arrived, the log sat on `sent`, and the
 * self-heal poller quietly covered for it. That was deferred rather than
 * designed away, on the grounds that `webhooks:write` was not worth asking for
 * until something used it. This is the something.
 */
final class Registration {

	/**
	 * The field this plugin owns on the token's webhook configuration.
	 */
	public const FIELD = 'single_sms_callback_url';

	/**
	 * How long to wait for a test event to come back, in seconds.
	 */
	public const TEST_TIMEOUT = 20;

	/**
	 * Point the token's SMS callback at this site.
	 *
	 * Reads the configuration first and writes **only** its own field.
	 * `campaign_callback_url` is deliberately left alone: a merchant may have it
	 * pointed at another system entirely, and silently redirecting that traffic
	 * here would break an integration this plugin knows nothing about.
	 *
	 * @return array<string, mixed>|WP_Error
	 */
	public static function register() {
		$url = Route::url();

		if ( ! self::is_reachable_from_outside( $url ) ) {
			return new WP_Error(
				'texttango_url_not_public',
				__( 'TextTango only accepts a callback on a public HTTPS address, and this site is not on one. Registering from here works once the site is live; until then, delivery receipts cannot reach a local or private host at all, however they are configured. Everything else — sending, sign-in codes, the message log — works as normal.', 'texttango' )
			);
		}

		$client = Client::from_settings();

		if ( is_wp_error( $client ) ) {
			return $client;
		}

		$updated = $client->update_webhook(
			array(
				self::FIELD => $url,
				'enabled'   => true,
			)
		);

		if ( is_wp_error( $updated ) ) {
			return $updated;
		}

		WebhookState::set(
			array(
				'registered_url' => $url,
				'registered_at'  => Clock::now(),
			)
		);

		return self::status();
	}

	/**
	 * What the platform currently believes, and what that means for this site.
	 *
	 * @return array<string, mixed>|WP_Error
	 */
	public static function status() {
		$client = Client::from_settings();

		if ( is_wp_error( $client ) ) {
			return $client;
		}

		$config = $client->webhook_config();

		if ( is_wp_error( $config ) ) {
			return $config;
		}

		return self::describe( self::attributes( $config ), Route::url() );
	}

	/**
	 * Read a configuration response into a verdict about this site.
	 *
	 * Pure, so every arrangement a merchant can be in — pointed here, pointed
	 * somewhere else, pointed nowhere, no secret — is describable without a
	 * network call standing behind it.
	 *
	 * @param array<string, mixed> $attributes Webhook configuration attributes.
	 * @param string               $url        This site's receipt URL.
	 * @return array<string, mixed>
	 */
	public static function describe( array $attributes, string $url ): array {
		$sms      = (string) ( $attributes[ self::FIELD ] ?? '' );
		$campaign = (string) ( $attributes['campaign_callback_url'] ?? '' );

		/*
		 * POST /v2/webhooks/test goes to the campaign URL when one is set, falling
		 * back to the SMS URL. A merchant with a campaign URL pointed elsewhere
		 * would watch the test never arrive and reasonably conclude the plugin is
		 * broken, so the difference is surfaced rather than left to be discovered.
		 */
		$test_goes_elsewhere = '' !== $campaign && ! self::same_endpoint( $campaign, $url );

		return array(
			'registered'          => self::same_endpoint( $sms, $url ),
			'enabled'             => ! empty( $attributes['enabled'] ),
			'has_secret'          => ! empty( $attributes['has_webhook_secret'] ),
			'sms_url'             => $sms,
			'campaign_url'        => $campaign,
			'test_goes_elsewhere' => $test_goes_elsewhere,
			'rotated_at'          => (string) ( $attributes['webhook_secret_rotated_at'] ?? '' ),
			'verified'            => WebhookState::is_verified(),
			'last_arrival'        => WebhookState::last_arrival(),
		);
	}

	/**
	 * Whether TextTango could actually reach this URL.
	 *
	 * The platform refuses a callback that is not public HTTPS, which is right —
	 * it cannot post to a host only this machine can see. Checked here rather
	 * than left to come back as a validation failure, so a developer on a `.test`
	 * domain is told what is actually true (this cannot work here, and will work
	 * once the site is live) instead of watching a request fail and wondering
	 * which of the token, the URL or the plugin is at fault.
	 *
	 * @param string $url Candidate callback URL.
	 */
	public static function is_reachable_from_outside( string $url ): bool {
		$parts = wp_parse_url( $url );

		if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
			return false;
		}

		if ( 'https' !== strtolower( (string) ( $parts['scheme'] ?? '' ) ) ) {
			return false;
		}

		return ! Client::is_local_host( (string) $parts['host'] );
	}

	/**
	 * Whether two callback URLs are the same endpoint.
	 *
	 * Compared after normalising the scheme, a trailing slash and case in the
	 * host, because a URL that came back from the platform having been through a
	 * form and a database is often cosmetically different from the one sent.
	 *
	 * @param string $a First URL.
	 * @param string $b Second URL.
	 */
	public static function same_endpoint( string $a, string $b ): bool {
		return '' !== $a && '' !== $b && self::canonical( $a ) === self::canonical( $b );
	}

	/**
	 * Reduce a URL to what actually identifies the endpoint.
	 *
	 * @param string $url URL as stored or configured.
	 */
	private static function canonical( string $url ): string {
		$parts = wp_parse_url( trim( $url ) );

		if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
			return '';
		}

		$path = untrailingslashit( (string) ( $parts['path'] ?? '' ) );

		return strtolower( (string) $parts['host'] ) . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) . $path;
	}

	/**
	 * Ask TextTango to send a test event here.
	 *
	 * Only dispatches. Whether it arrives is answered by {@see WebhookState},
	 * which is written at the receiving end — asking the sender whether its own
	 * message was delivered would prove nothing about this site.
	 *
	 * @return array<string, mixed>|WP_Error
	 */
	public static function send_test() {
		$client = Client::from_settings();

		if ( is_wp_error( $client ) ) {
			return $client;
		}

		// Noted first so arrival can be told apart from a stamp left by an earlier
		// test — otherwise a failed test would look successful because a previous
		// one had once worked.
		$before = WebhookState::last_arrival();

		$sent = $client->test_webhook();

		if ( is_wp_error( $sent ) ) {
			return $sent;
		}

		return array(
			'dispatched' => true,
			'since'      => $before,
			'timeout'    => self::TEST_TIMEOUT,
		);
	}

	/**
	 * Whether anything has arrived since a given moment.
	 *
	 * @param int $since Timestamp recorded before the test was dispatched.
	 */
	public static function arrived_since( int $since ): bool {
		return WebhookState::last_arrival() > $since;
	}

	/**
	 * Issue a new signing secret and store it.
	 *
	 * The dangerous one. The old secret stops verifying the moment the endpoint is
	 * called, and the new one is disclosed in that one response — so a site that
	 * cannot store what comes back is left unable to receive anything, with no way
	 * to recover except rotating again.
	 *
	 * Which is why encryption is proven **before** the call rather than after.
	 * A site with broken sodium or freshly rotated salts must not be able to reach
	 * the point where it destroys a working secret it has no means of replacing.
	 * Refusing to start is the only safe way to fail here.
	 *
	 * @return array<string, mixed>|WP_Error
	 */
	public static function rotate_secret() {
		if ( ! self::can_store_secret() ) {
			return new WP_Error(
				'texttango_rotation_unsafe',
				__( 'This site cannot store an encrypted secret right now, so rotating would leave it unable to receive delivery receipts. Nothing has been changed. Check that the sodium extension is available and that TEXTTANGO_ENCRYPTION_KEY, if defined, is a valid base64 32-byte value.', 'texttango' )
			);
		}

		$client = Client::from_settings();

		if ( is_wp_error( $client ) ) {
			return $client;
		}

		$response = $client->rotate_webhook_secret();

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$secret = self::extract_secret( $response );

		if ( '' === $secret ) {
			return new WP_Error(
				'texttango_rotation_empty',
				__( 'TextTango rotated the secret but did not return it, so this site can no longer verify receipts. Set a new secret from the dashboard, or rotate again.', 'texttango' )
			);
		}

		if ( ! Settings::set_webhook_secret( $secret ) ) {
			return new WP_Error(
				'texttango_rotation_unstored',
				__( 'A new secret was issued but could not be saved, so receipts will not verify until one is stored. Rotate again once encryption is working.', 'texttango' )
			);
		}

		WebhookState::set( array( 'rotated_at' => Clock::now() ) );

		/**
		 * Fires after the webhook signing secret has been replaced.
		 *
		 * The secret itself is deliberately not passed. Anything that needs it can
		 * read it through {@see Settings::get_webhook_secret()}, and a hook argument
		 * would put a live credential into every listener and every debug log that
		 * happens to dump hook arguments.
		 */
		do_action( 'texttango_webhook_secret_rotated' );

		return array( 'rotated' => true );
	}

	/**
	 * Whether a secret written now could be read back.
	 *
	 * A round trip on a throwaway value, not an inspection of the environment:
	 * what matters is the actual outcome, and the ways this can fail — a missing
	 * extension, a malformed key constant, salts rotated since the last write —
	 * do not all look the same from the outside.
	 */
	public static function can_store_secret(): bool {
		$probe     = 'texttango-rotation-probe';
		$encrypted = Crypto::encrypt( $probe );

		return null !== $encrypted && Crypto::decrypt( $encrypted ) === $probe;
	}

	/**
	 * Pull the secret out of a rotate response.
	 *
	 * @param array<string, mixed> $response Decoded response body.
	 */
	public static function extract_secret( array $response ): string {
		$data       = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : $response;
		$attributes = isset( $data['attributes'] ) && is_array( $data['attributes'] ) ? $data['attributes'] : $data;

		$secret = $attributes['webhook_secret'] ?? $data['webhook_secret'] ?? '';

		return is_string( $secret ) ? trim( $secret ) : '';
	}

	/**
	 * The attributes out of a webhook configuration response.
	 *
	 * @param array<string, mixed> $response Decoded response body.
	 * @return array<string, mixed>
	 */
	public static function attributes( array $response ): array {
		$data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : $response;

		if ( isset( $data['attributes'] ) && is_array( $data['attributes'] ) ) {
			return $data['attributes'];
		}

		return is_array( $data ) ? $data : array();
	}
}
