<?php
/**
 * Inbound webhook signature verification.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Webhook;

use TextTango\Support\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Decides whether an inbound delivery receipt really came from TextTango
 * (spec §9, Appendix B).
 *
 * The route is public — it has to be, TextTango calls it unauthenticated — so
 * this check is the only thing separating a genuine receipt from anyone on the
 * internet marking messages as delivered, or writing notes onto orders.
 *
 * Three properties matter and each is easy to get subtly wrong:
 *
 * 1. The HMAC is computed over the **raw** body. Re-encoding a decoded payload
 *    changes key order and whitespace, and the signature would never match.
 * 2. Comparison is `hash_equals`, not `===`, so a wrong signature cannot be
 *    recovered byte by byte from how long the comparison took.
 * 3. It fails closed. No secret configured means every request is rejected,
 *    rather than every request being accepted.
 */
final class SignatureVerifier {

	/**
	 * Header TextTango signs with.
	 */
	public const HEADER = 'X-TextTango-Signature';

	/**
	 * Same header as WordPress presents it on a REST request.
	 */
	public const HEADER_KEY = 'x_texttango_signature';

	/**
	 * Hash used for the signature.
	 */
	private const ALGORITHM = 'sha256';

	/**
	 * Whether a signature matches a body.
	 *
	 * @param string $raw_body  Body exactly as received.
	 * @param string $signature Signature from the request header.
	 * @param string $secret    The `whsec_…` signing secret.
	 */
	public static function matches( string $raw_body, string $signature, string $secret ): bool {
		$signature = self::normalize( $signature );

		// Fail closed. An unconfigured secret must reject everything, and an empty
		// signature must never be compared against an empty expectation.
		if ( '' === $secret || '' === $signature || '' === $raw_body ) {
			return false;
		}

		$expected = hash_hmac( self::ALGORITHM, $raw_body, $secret );

		return hash_equals( $expected, $signature );
	}

	/**
	 * Whether a request carries a valid signature, using the stored secret.
	 *
	 * @param string $raw_body  Body exactly as received.
	 * @param string $signature Signature from the request header.
	 */
	public static function verify( string $raw_body, string $signature ): bool {
		return self::matches( $raw_body, $signature, Settings::get_webhook_secret() );
	}

	/**
	 * Tidy a signature header into a bare lowercase hex digest.
	 *
	 * Some senders prefix the algorithm; accepting `sha256=…` as well as the bare
	 * digest costs nothing and avoids rejecting a legitimate receipt over
	 * formatting.
	 *
	 * @param string $signature Raw header value.
	 */
	private static function normalize( string $signature ): string {
		$signature = trim( $signature );

		if ( 0 === stripos( $signature, self::ALGORITHM . '=' ) ) {
			$signature = substr( $signature, strlen( self::ALGORITHM ) + 1 );
		}

		$signature = strtolower( trim( $signature ) );

		// Anything that is not a hex digest cannot be a signature, and rejecting it
		// here keeps hash_equals() comparing two same-shaped strings.
		if ( 1 !== preg_match( '/^[a-f0-9]{64}$/', $signature ) ) {
			return '';
		}

		return $signature;
	}

	/**
	 * Whether a signing secret is configured at all.
	 */
	public static function is_configured(): bool {
		return '' !== Settings::get_webhook_secret();
	}
}
