<?php
/**
 * One-time proof that this site asked for a measurement.
 *
 * Our service will not measure a domain on request alone; it fetches this
 * nonce back from the site's own public route first. The value is random,
 * short lived, single purpose and reveals nothing about the site, so serving
 * it publicly is safe: knowing it lets you measure this very site, nothing else.
 *
 * @package MarketingConsole
 */

namespace MarketingConsole\Admin\Free;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Issues and publishes the domain-verification nonce.
 */
final class PsiNonce {

	public const OPTION    = 'speedlyst_free_psi_nonce';
	public const NAMESPACE = 'marketing-console/v1';
	public const ROUTE     = '/psi-nonce';

	/**
	 * Window in which the service must fetch the nonce back.
	 */
	private const TTL_SECONDS = 300;

	/**
	 * @return void
	 */
	public function register(): void {
		add_action( 'rest_api_init', array( $this, 'register_route' ) );
	}

	/**
	 * @return void
	 */
	public function register_route(): void {
		register_rest_route(
			self::NAMESPACE,
			self::ROUTE,
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'serve' ),
				'permission_callback' => '__return_true',
			)
		);
	}

	/**
	 * Mint a fresh nonce for one measurement and remember it.
	 *
	 * The write is read back: a nonce the service cannot fetch from the site is
	 * useless, and reporting it as issued would turn a local storage failure
	 * into a confusing "domain not verified" error later.
	 *
	 * @return string Empty string when the nonce could not be persisted.
	 */
	public function issue(): string {
		$nonce = str_replace( '-', '', wp_generate_uuid4() ) . str_replace( '-', '', wp_generate_uuid4() );
		$value = array(
			'nonce'      => $nonce,
			'expires_at' => time() + self::TTL_SECONDS,
		);

		$saved = update_option( self::OPTION, $value, false );

		if ( ! $saved ) {
			$stored = get_option( self::OPTION, array() );
			$saved  = is_array( $stored ) && isset( $stored['nonce'] ) && is_string( $stored['nonce'] ) && hash_equals( $stored['nonce'], $nonce );
		}

		return $saved ? $nonce : '';
	}

	/**
	 * Serve the pending nonce, or nothing when no measurement is in flight.
	 *
	 * @return \WP_REST_Response
	 */
	public function serve() {
		$stored = get_option( self::OPTION, array() );

		if ( ! is_array( $stored ) || ! isset( $stored['nonce'], $stored['expires_at'] ) || ! is_string( $stored['nonce'] ) || ! is_int( $stored['expires_at'] ) || $stored['expires_at'] < time() ) {
			return new \WP_REST_Response( array( 'nonce' => null ), 404 );
		}

		return new \WP_REST_Response( array( 'nonce' => $stored['nonce'] ), 200 );
	}

	/**
	 * Burn the nonce once the measurement is done.
	 *
	 * Only the caller's own nonce is removed: an unconditional delete used to
	 * burn the nonce of a second measurement that started in the meantime.
	 *
	 * @param string $nonce The nonce this measurement was issued.
	 * @return void
	 */
	public function clear( string $nonce = '' ): void {
		if ( '' === $nonce ) {
			delete_option( self::OPTION );

			return;
		}

		$stored = get_option( self::OPTION, array() );

		if ( is_array( $stored ) && isset( $stored['nonce'] ) && is_string( $stored['nonce'] ) && hash_equals( $stored['nonce'], $nonce ) ) {
			delete_option( self::OPTION );
		}
	}
}
