<?php
/**
 * Shared secret, timestamp skew, and replay protection for mail webhooks.
 *
 * @package EmailSmtp
 * @since 1.4.0
 */

namespace EmailSmtp\Includes\Webhooks;

use EmailSmtp\Includes\CredentialManager;
use EmailSmtp\Includes\EmailRouter;

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

final class WebhookSecurity {

	public const QUERY_PARAM = 'wpps_mail_webhook';

	private const REPLAY_TTL = 600;

	private const SKEW_SECONDS = 300;

	public static function is_enabled(): bool {
		$settings = EmailRouter::get_settings();
		return ! empty( $settings['webhook_enabled'] ) && (string) $settings['webhook_enabled'] !== '0';
	}

	public static function shared_secret(): string {
		$settings = EmailRouter::get_settings();
		return CredentialManager::resolve(
			$settings,
			'webhook_secret',
			'WP_POWERSUITE_MAIL_WEBHOOK_SECRET',
			'WP_POWERSUITE_MAIL_WEBHOOK_SECRET'
		);
	}

	/**
	 * Require Authorization: Bearer <secret> or X-WPPS-Webhook-Secret header.
	 */
	public static function verify_shared_secret(): bool {
		$secret = self::shared_secret();
		if ( $secret === '' ) {
			return false;
		}

		$provided = '';
		$auth     = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? (string) $_SERVER['HTTP_AUTHORIZATION'] : '';
		if ( $auth === '' && isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
			$auth = (string) $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
		}
		if ( preg_match( '/^\s*Bearer\s+(.+)$/i', $auth, $m ) ) {
			$provided = trim( $m[1] );
		}
		if ( $provided === '' && isset( $_SERVER['HTTP_X_WPPS_WEBHOOK_SECRET'] ) ) {
			$provided = trim( (string) $_SERVER['HTTP_X_WPPS_WEBHOOK_SECRET'] );
		}

		if ( $provided === '' ) {
			return false;
		}

		return hash_equals( $secret, $provided );
	}

	/**
	 * Reject timestamps outside the allowed skew window.
	 */
	public static function timestamp_ok( int $timestamp ): bool {
		if ( $timestamp <= 0 ) {
			return false;
		}
		return abs( time() - $timestamp ) <= self::SKEW_SECONDS;
	}

	/**
	 * Atomically claim a one-time replay token keyed by provider + event fingerprint.
	 *
	 * Returns false when the fingerprint was already claimed and has not expired.
	 * Uses WordPress transient option names so expired rows are cleaned by core.
	 */
	public static function claim_replay_token( string $provider, string $fingerprint ): bool {
		$fingerprint = substr( hash( 'sha256', $provider . '|' . $fingerprint ), 0, 40 );
		$key         = 'wpps_mail_wh_' . $fingerprint;

		if ( wp_using_ext_object_cache() ) {
			return (bool) wp_cache_add( $key, '1', 'transient', self::REPLAY_TTL );
		}

		// Atomic claim via timeout option insert (same schema as set_transient).
		$timeout_option = '_transient_timeout_' . $key;
		$value_option   = '_transient_' . $key;
		$expiration     = (string) ( time() + self::REPLAY_TTL );

		if ( add_option( $timeout_option, $expiration, '', 'no' ) ) {
			add_option( $value_option, '1', '', 'no' );
			return true;
		}

		$existing = (int) get_option( $timeout_option, 0 );
		if ( $existing >= time() ) {
			return false;
		}

		// Expired — remove both rows then re-claim so add_option stays atomic.
		delete_option( $timeout_option );
		delete_option( $value_option );
		if ( ! add_option( $timeout_option, $expiration, '', 'no' ) ) {
			return false;
		}
		add_option( $value_option, '1', '', 'no' );
		return true;
	}

	public static function endpoint_url( string $provider_id ): string {
		return add_query_arg(
			self::QUERY_PARAM,
			sanitize_key( $provider_id ),
			home_url( '/' )
		);
	}

	/**
	 * Mailgun-style HMAC: hex(hmac_sha256(timestamp + token, key)).
	 */
	public static function hmac_sha256_hex( string $data, string $key ): string {
		return hash_hmac( 'sha256', $data, $key );
	}

	/**
	 * Constant-time compare for hex digests.
	 */
	public static function hash_equals_hex( string $expected, string $actual ): bool {
		$expected = strtolower( trim( $expected ) );
		$actual   = strtolower( trim( $actual ) );
		if ( $expected === '' || $actual === '' || strlen( $expected ) !== strlen( $actual ) ) {
			return false;
		}
		return hash_equals( $expected, $actual );
	}

	/**
	 * Bird / Standard Webhooks signing secret (whsec_…).
	 */
	public static function bird_signing_secret(): string {
		$settings = EmailRouter::get_settings();
		return CredentialManager::resolve(
			$settings,
			'webhook_bird_signing_secret',
			'WP_POWERSUITE_BIRD_WEBHOOK_SECRET',
			'WP_POWERSUITE_BIRD_WEBHOOK_SECRET'
		);
	}

	/**
	 * Whether the request carries Standard Webhooks signature headers.
	 */
	public static function has_standard_webhook_headers(): bool {
		$id  = self::request_header( 'webhook-id' );
		$ts  = self::request_header( 'webhook-timestamp' );
		$sig = self::request_header( 'webhook-signature' );
		return $id !== '' && $ts !== '' && $sig !== '';
	}

	/**
	 * Read a request header (supports webhook-id → HTTP_WEBHOOK_ID).
	 */
	public static function request_header( string $name ): string {
		$server_key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
		if ( isset( $_SERVER[ $server_key ] ) ) {
			return trim( (string) $_SERVER[ $server_key ] );
		}
		// Some stacks expose via getallheaders().
		if ( function_exists( 'getallheaders' ) ) {
			foreach ( getallheaders() as $header => $value ) {
				if ( strtolower( (string) $header ) === strtolower( $name ) ) {
					return trim( (string) $value );
				}
			}
		}
		return '';
	}

	/**
	 * Verify a Standard Webhooks signature (Bird Email).
	 *
	 * @link https://bird.com/en-us/docs/guides/webhooks
	 */
	public static function verify_standard_webhook( string $raw_body, string $secret ): bool {
		$secret = trim( $secret );
		if ( $secret === '' ) {
			return false;
		}

		$id        = self::request_header( 'webhook-id' );
		$timestamp = self::request_header( 'webhook-timestamp' );
		$sig_header = self::request_header( 'webhook-signature' );
		if ( $id === '' || $timestamp === '' || $sig_header === '' ) {
			return false;
		}
		if ( ! self::timestamp_ok( (int) $timestamp ) ) {
			return false;
		}

		$key_b64 = str_starts_with( $secret, 'whsec_' ) ? substr( $secret, 6 ) : $secret;
		$key     = base64_decode( $key_b64, true );
		if ( ! is_string( $key ) || $key === '' ) {
			return false;
		}

		$expected_raw = hash_hmac( 'sha256', $id . '.' . $timestamp . '.' . $raw_body, $key, true );
		$expected_b64 = base64_encode( $expected_raw );

		foreach ( preg_split( '/\s+/', $sig_header ) ?: [] as $part ) {
			$part = trim( (string) $part );
			if ( $part === '' || ! str_starts_with( $part, 'v1,' ) ) {
				continue;
			}
			$sig_b64 = substr( $part, 3 );
			if ( $sig_b64 !== '' && hash_equals( $expected_b64, $sig_b64 ) ) {
				return true;
			}
			$sig_raw = base64_decode( $sig_b64, true );
			if ( is_string( $sig_raw ) && $sig_raw !== '' && hash_equals( $expected_raw, $sig_raw ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Whether a URL host is an SNS regional amazonaws.com endpoint.
	 */
	public static function is_sns_amazonaws_host( string $host ): bool {
		$host = strtolower( trim( $host ) );
		if ( $host === '' || str_contains( $host, '..' ) ) {
			return false;
		}
		// sns.amazonaws.com or sns.<region>.amazonaws.com (not *.amazonaws.com broadly).
		return (bool) preg_match( '/^sns(\.[a-z0-9-]+)?\.amazonaws\.com$/', $host );
	}

	/**
	 * Verify an Amazon SNS HTTP(S) notification / subscription payload.
	 *
	 * @param array<string, mixed> $data Decoded SNS JSON.
	 * @see https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
	 */
	public static function verify_sns_signature( array $data ): bool {
		$version = (string) ( $data['SignatureVersion'] ?? '1' );
		if ( ! in_array( $version, [ '1', '2' ], true ) ) {
			return false;
		}
		$signature = (string) ( $data['Signature'] ?? '' );
		$cert_url  = (string) ( $data['SigningCertURL'] ?? $data['SigningCertUrl'] ?? '' );
		if ( $signature === '' || $cert_url === '' ) {
			return false;
		}

		$parts = wp_parse_url( $cert_url );
		if ( ! is_array( $parts ) ) {
			return false;
		}
		$scheme = strtolower( (string) ( $parts['scheme'] ?? '' ) );
		$host   = strtolower( (string) ( $parts['host'] ?? '' ) );
		$path   = (string) ( $parts['path'] ?? '' );
		if ( $scheme !== 'https' || ! self::is_sns_amazonaws_host( $host ) ) {
			return false;
		}
		if ( $path === '' || ! str_ends_with( strtolower( $path ), '.pem' ) ) {
			return false;
		}
		if ( ! wp_http_validate_url( $cert_url ) ) {
			return false;
		}

		$sign_string = self::build_sns_sign_string( $data );
		if ( $sign_string === '' ) {
			return false;
		}

		$cert_key = 'wpps_sns_cert_' . md5( $cert_url );
		$pem      = get_transient( $cert_key );
		if ( ! is_string( $pem ) || $pem === '' ) {
			$response = wp_remote_get(
				$cert_url,
				[
					'timeout'     => 10,
					'redirection' => 0,
				]
			);
			if ( is_wp_error( $response ) || (int) wp_remote_retrieve_response_code( $response ) !== 200 ) {
				return false;
			}
			$pem = (string) wp_remote_retrieve_body( $response );
			if ( $pem === '' || ! str_contains( $pem, 'BEGIN CERTIFICATE' ) ) {
				return false;
			}
			set_transient( $cert_key, $pem, HOUR_IN_SECONDS );
		}

		$decoded = base64_decode( $signature, true );
		if ( ! is_string( $decoded ) || $decoded === '' ) {
			return false;
		}

		$algo   = $version === '2' ? OPENSSL_ALGO_SHA256 : OPENSSL_ALGO_SHA1;
		$verify = openssl_verify( $sign_string, $decoded, $pem, $algo );
		return $verify === 1;
	}

	/**
	 * @param array<string, mixed> $data
	 */
	public static function build_sns_sign_string( array $data ): string {
		$type = (string) ( $data['Type'] ?? '' );
		$fields = match ( $type ) {
			'Notification' => [ 'Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type' ],
			'SubscriptionConfirmation', 'UnsubscribeConfirmation' => [ 'Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type' ],
			default => [],
		};
		if ( $fields === [] ) {
			return '';
		}

		$out = '';
		foreach ( $fields as $field ) {
			if ( ! array_key_exists( $field, $data ) ) {
				continue;
			}
			$value = $data[ $field ];
			if ( ! is_scalar( $value ) ) {
				continue;
			}
			$out .= $field . "\n" . (string) $value . "\n";
		}
		return $out;
	}
}
