<?php
/**
 * Site-local mail event webhooks (?wpps_mail_webhook=provider).
 *
 * Requires a shared secret header plus provider-native verification when available.
 *
 * @package EmailSmtp
 * @since 1.4.0
 */

namespace EmailSmtp\Includes\Webhooks;

use EmailSmtp\Includes\EmailRouter;
use EmailSmtp\Includes\ErrorNormalizer;
use WP_PowerSuite\Core\OptionStoreModuleSettingsRepository;

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

final class WebhookRouter {

	/** @var list<string> */
	private const PROVIDERS = [ 'sendgrid', 'mailgun', 'postmark', 'amazon-ses', 'resend', 'brevo', 'sparkpost', 'bird' ];

	public static function register(): void {
		add_action( 'init', [ self::class, 'dispatch' ], 0 );
	}

	public static function dispatch(): void {
		if ( empty( $_GET[ WebhookSecurity::QUERY_PARAM ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			return;
		}

		if ( ! OptionStoreModuleSettingsRepository::isModuleEnabled( 'email-smtp' ) || ! WebhookSecurity::is_enabled() ) {
			self::respond( 404, [ 'error' => 'disabled' ] );
		}

		$provider = sanitize_key( wp_unslash( (string) $_GET[ WebhookSecurity::QUERY_PARAM ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! in_array( $provider, self::PROVIDERS, true ) ) {
			self::respond( 400, [ 'error' => 'invalid_provider' ] );
		}

		$max_body = (int) apply_filters( 'wp_powersuite_email_delivery_webhook_max_bytes', 1048576 ); // 1 MiB.
		$max_body = max( 16384, min( 5242880, $max_body ) );
		$raw      = (string) file_get_contents( 'php://input', false, null, 0, $max_body + 1 );
		if ( strlen( $raw ) > $max_body ) {
			self::respond( 413, [ 'error' => 'payload_too_large' ] );
		}
		if ( $raw === '' && ! empty( $_POST ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			$raw = wp_json_encode( wp_unslash( $_POST ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			if ( is_string( $raw ) && strlen( $raw ) > $max_body ) {
				self::respond( 413, [ 'error' => 'payload_too_large' ] );
			}
		}

		$bird_secret  = WebhookSecurity::bird_signing_secret();
		$bird_headers = WebhookSecurity::has_standard_webhook_headers();

		// Bird requires Standard Webhooks (whsec) — shared-secret-only is not accepted.
		if ( $provider === 'bird' ) {
			if ( $bird_secret === '' ) {
				self::respond( 401, [ 'error' => 'secret_not_configured' ] );
			}
			if ( ! $bird_headers ) {
				self::respond( 401, [ 'error' => 'unauthorized' ] );
			}
		}

		// Bird Standard Webhooks count as native auth only after signature verification.
		// Presence of webhook-* headers alone must never skip the shared-secret gate
		// (classic SparkPost msys payloads are otherwise unauthenticated).
		$bird_signed_ok = false;
		if (
			in_array( $provider, [ 'bird', 'sparkpost' ], true )
			&& $bird_secret !== ''
			&& $bird_headers
		) {
			$bird_signed_ok = WebhookSecurity::verify_standard_webhook( $raw, $bird_secret );
		}
		if ( $provider === 'bird' && ! $bird_signed_ok ) {
			self::respond( 401, [ 'error' => 'unauthorized' ] );
		}

		// Shared secret header required unless the provider supplies a native signature
		// verified here (Bird) or later in the parser (Mailgun HMAC, SendGrid ECDSA).
		$native_auth = in_array( $provider, [ 'mailgun', 'sendgrid' ], true ) || $bird_signed_ok;
		if ( ! $native_auth && ! WebhookSecurity::verify_shared_secret() ) {
			self::respond( 401, [ 'error' => 'unauthorized' ] );
		}
		if ( $provider === 'sendgrid' ) {
			$settings = EmailRouter::get_settings();
			$has_vkey = trim( (string) ( $settings['webhook_sendgrid_verification_key'] ?? '' ) ) !== '';
			if ( ! $has_vkey && ! WebhookSecurity::verify_shared_secret() ) {
				self::respond( 401, [ 'error' => 'unauthorized' ] );
			}
		}
		if ( $provider !== 'bird' && WebhookSecurity::shared_secret() === '' && ! $bird_signed_ok ) {
			self::respond( 401, [ 'error' => 'secret_not_configured' ] );
		}

		// Verify/parse BEFORE claiming the replay token. Claiming first let unauthenticated
		// Mailgun/SendGrid bodies burn the fingerprint and cause legitimate retries to be ignored.
		$events = [];
		try {
			$events = match ( $provider ) {
				'sendgrid'   => self::parse_sendgrid( $raw ),
				'mailgun'    => self::parse_mailgun( $raw ),
				'postmark'   => self::parse_postmark( $raw ),
				'amazon-ses' => self::parse_amazon_ses( $raw ),
				'resend'     => self::parse_resend( $raw ),
				'brevo'      => self::parse_brevo( $raw ),
				'sparkpost'  => self::parse_sparkpost( $raw ),
				'bird'       => self::parse_bird( $raw, true ),
				default      => [],
			};
		} catch ( \Throwable $e ) {
			self::respond( 400, [ 'error' => 'parse_failed', 'detail' => ErrorNormalizer::redact( $e->getMessage() ) ] );
		}

		// Prefer Standard Webhooks delivery id for Bird (stable across retries).
		$webhook_id  = WebhookSecurity::request_header( 'webhook-id' );
		$fingerprint = $webhook_id !== ''
			? $webhook_id
			: substr( hash( 'sha256', $provider . '|' . $raw ), 0, 64 );
		if ( ! WebhookSecurity::claim_replay_token( $provider, $fingerprint ) ) {
			// 200 so providers that retry (Bird) do not keep re-delivering.
			self::respond( 200, [ 'ok' => true, 'updated' => 0, 'duplicate' => true ] );
		}

		// Bird Email sends use the SparkPost connection id in Email Logs.
		$apply_provider = $provider === 'bird' ? 'sparkpost' : $provider;

		$updated = 0;
		foreach ( $events as $event ) {
			if ( self::apply_event( $apply_provider, $event ) ) {
				++$updated;
			}
		}

		self::respond( 200, [ 'ok' => true, 'updated' => $updated ] );
	}

	/**
	 * @return list<array{message_id:string, status:string, detail:string}>
	 */
	private static function parse_sendgrid( string $raw ): array {
		$settings = EmailRouter::get_settings();
		$vkey     = trim( (string) ( $settings['webhook_sendgrid_verification_key'] ?? '' ) );
		if ( $vkey !== '' && ! self::verify_sendgrid_ecdsa( $raw, $vkey ) ) {
			throw new \RuntimeException( 'SendGrid signature verification failed.' );
		}

		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			return [];
		}
		// SendGrid sends a JSON array of events.
		$items = array_is_list( $data ) ? $data : [ $data ];
		$out   = [];
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$sg_event = sanitize_key( (string) ( $item['event'] ?? '' ) );
			$status   = self::map_sendgrid_event( $sg_event );
			if ( $status === '' ) {
				continue;
			}
			$message_id = (string) ( $item['sg_message_id'] ?? $item['smtp-id'] ?? '' );
			$message_id = preg_replace( '/\.[^.]+\.[^.]+$/', '', $message_id ) ?? $message_id;
			$out[]      = [
				'message_id' => sanitize_text_field( $message_id ),
				'status'     => $status,
				'detail'     => sanitize_text_field( (string) ( $item['reason'] ?? $sg_event ) ),
			];
		}
		return $out;
	}

	private static function verify_sendgrid_ecdsa( string $raw, string $verification_key ): bool {
		$signature = isset( $_SERVER['HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_SIGNATURE'] )
			? (string) $_SERVER['HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_SIGNATURE']
			: '';
		$timestamp = isset( $_SERVER['HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_TIMESTAMP'] )
			? (string) $_SERVER['HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_TIMESTAMP']
			: '';
		if ( $signature === '' || $timestamp === '' || ! WebhookSecurity::timestamp_ok( (int) $timestamp ) ) {
			return false;
		}
		if ( ! function_exists( 'openssl_verify' ) ) {
			return false;
		}

		$pem = $verification_key;
		if ( ! str_contains( $pem, 'BEGIN PUBLIC KEY' ) ) {
			$pem = "-----BEGIN PUBLIC KEY-----\n" . trim( chunk_split( preg_replace( '/\s+/', '', $verification_key ) ?? '', 64, "\n" ) ) . "-----END PUBLIC KEY-----\n";
		}
		$payload = $timestamp . $raw;
		$decoded = base64_decode( $signature, true );
		if ( $decoded === false ) {
			return false;
		}
		$ok = openssl_verify( $payload, $decoded, $pem, OPENSSL_ALGO_SHA256 );
		return $ok === 1;
	}

	private static function map_sendgrid_event( string $event ): string {
		return match ( $event ) {
			'delivered' => 'delivered',
			'bounce', 'blocked' => 'bounced',
			'spamreport' => 'complained',
			'dropped' => 'rejected',
			'deferred' => 'deferred',
			'processed' => 'accepted',
			default => '',
		};
	}

	/**
	 * @return list<array{message_id:string, status:string, detail:string}>
	 */
	private static function parse_mailgun( string $raw ): array {
		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			$data = wp_unslash( $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		}

		$sig_block = is_array( $data['signature'] ?? null ) ? $data['signature'] : $data;
		$timestamp = (string) ( $sig_block['timestamp'] ?? '' );
		$token     = (string) ( $sig_block['token'] ?? '' );
		$signature = (string) ( $sig_block['signature'] ?? '' );
		$secret    = WebhookSecurity::shared_secret();
		if ( $timestamp === '' || $token === '' || $signature === '' ) {
			throw new \RuntimeException( 'Mailgun signature fields missing.' );
		}
		if ( ! WebhookSecurity::timestamp_ok( (int) $timestamp ) ) {
			throw new \RuntimeException( 'Mailgun timestamp out of range.' );
		}
		$expected = WebhookSecurity::hmac_sha256_hex( $timestamp . $token, $secret );
		if ( ! WebhookSecurity::hash_equals_hex( $expected, $signature ) ) {
			throw new \RuntimeException( 'Mailgun signature mismatch.' );
		}

		$event_data = is_array( $data['event-data'] ?? null ) ? $data['event-data'] : $data;
		$event      = sanitize_key( (string) ( $event_data['event'] ?? $data['event'] ?? '' ) );
		$status     = match ( $event ) {
			'delivered' => 'delivered',
			'failed', 'bounced', 'permanent_fail', 'temporary_fail' => 'bounced',
			'complained' => 'complained',
			'rejected' => 'rejected',
			'accepted' => 'accepted',
			default => '',
		};
		if ( $status === '' ) {
			return [];
		}

		$message_id = '';
		if ( is_array( $event_data['message']['headers'] ?? null ) ) {
			$message_id = (string) ( $event_data['message']['headers']['message-id'] ?? '' );
		}
		if ( $message_id === '' ) {
			$message_id = (string) ( $event_data['message']['headers']['message_id'] ?? $data['Message-Id'] ?? $data['message-id'] ?? '' );
		}
		$message_id = trim( $message_id, '<>' );

		return [
			[
				'message_id' => sanitize_text_field( $message_id ),
				'status'     => $status,
				'detail'     => sanitize_text_field( (string) ( $event_data['severity']['description'] ?? $event ) ),
			],
		];
	}

	/**
	 * @return list<array{message_id:string, status:string, detail:string}>
	 */
	private static function parse_postmark( string $raw ): array {
		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			return [];
		}
		$record_type = sanitize_key( (string) ( $data['RecordType'] ?? '' ) );
		$status      = match ( $record_type ) {
			'delivery' => 'delivered',
			'bounce' => 'bounced',
			'spamcomplaint' => 'complained',
			default => '',
		};
		if ( $status === '' ) {
			return [];
		}
		$message_id = (string) ( $data['MessageID'] ?? '' );
		return [
			[
				'message_id' => sanitize_text_field( $message_id ),
				'status'     => $status,
				'detail'     => sanitize_text_field( (string) ( $data['Description'] ?? $data['Type'] ?? $record_type ) ),
			],
		];
	}

	/**
	 * @return list<array{message_id:string, status:string, detail:string}>
	 */
	private static function parse_amazon_ses( string $raw ): array {
		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			return [];
		}

		// SNS envelopes must verify; reject unsigned Subscription/Notification wrappers.
		$sns_type = (string) ( $data['Type'] ?? '' );
		if ( in_array( $sns_type, [ 'SubscriptionConfirmation', 'Notification', 'UnsubscribeConfirmation' ], true ) ) {
			if ( ! WebhookSecurity::verify_sns_signature( $data ) ) {
				throw new \RuntimeException( 'Amazon SNS signature verification failed.' );
			}
		}

		// SNS subscription confirmation — acknowledge without log updates.
		if ( ( $data['Type'] ?? '' ) === 'SubscriptionConfirmation' && ! empty( $data['SubscribeURL'] ) ) {
			$url  = esc_url_raw( (string) $data['SubscribeURL'] );
			$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
			if (
				$url !== ''
				&& WebhookSecurity::is_sns_amazonaws_host( $host )
				&& wp_http_validate_url( $url )
			) {
				wp_remote_get(
					$url,
					[
						'timeout'     => 15,
						'redirection' => 0,
					]
				);
			}
			return [];
		}

		$message = $data;
		if ( ( $data['Type'] ?? '' ) === 'Notification' && isset( $data['Message'] ) && is_string( $data['Message'] ) ) {
			$inner = json_decode( $data['Message'], true );
			if ( is_array( $inner ) ) {
				$message = $inner;
			}
		}

		$notification_type = sanitize_key( (string) ( $message['notificationType'] ?? $message['eventType'] ?? '' ) );
		$status            = match ( $notification_type ) {
			'delivery' => 'delivered',
			'bounce' => 'bounced',
			'complaint' => 'complained',
			'reject' => 'rejected',
			'deliverydelay' => 'deferred',
			'send' => 'accepted',
			default => '',
		};
		if ( $status === '' ) {
			return [];
		}

		$message_id = (string) ( $message['mail']['messageId'] ?? '' );
		return [
			[
				'message_id' => sanitize_text_field( $message_id ),
				'status'     => $status,
				'detail'     => sanitize_text_field( $notification_type ),
			],
		];
	}

	/**
	 * @return list<array{message_id:string, status:string, detail:string}>
	 */
	private static function parse_resend( string $raw ): array {
		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			return [];
		}
		$type   = sanitize_key( str_replace( '.', '_', (string) ( $data['type'] ?? '' ) ) );
		$status = match ( $type ) {
			'email_delivered' => 'delivered',
			'email_bounced', 'email_failed' => 'bounced',
			'email_complained' => 'complained',
			'email_delivery_delayed' => 'deferred',
			'email_sent' => 'accepted',
			default => '',
		};
		if ( $status === '' ) {
			return [];
		}
		$message_id = (string) ( $data['data']['email_id'] ?? $data['data']['id'] ?? '' );
		return [
			[
				'message_id' => sanitize_text_field( $message_id ),
				'status'     => $status,
				'detail'     => sanitize_text_field( (string) ( $data['type'] ?? '' ) ),
			],
		];
	}

	/**
	 * @return list<array{message_id:string, status:string, detail:string}>
	 */
	private static function parse_brevo( string $raw ): array {
		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			return [];
		}
		$event  = sanitize_key( (string) ( $data['event'] ?? '' ) );
		$status = match ( $event ) {
			'delivered' => 'delivered',
			'hard_bounce', 'soft_bounce', 'blocked', 'invalid' => 'bounced',
			'spam' => 'complained',
			'request' => 'accepted',
			'deferred' => 'deferred',
			default => '',
		};
		if ( $status === '' ) {
			return [];
		}
		$message_id = (string) ( $data['message-id'] ?? $data['messageId'] ?? '' );
		return [
			[
				'message_id' => sanitize_text_field( $message_id ),
				'status'     => $status,
				'detail'     => sanitize_text_field( $event ),
			],
		];
	}

	/**
	 * SparkPost classic msys batches, or Bird Email Standard Webhooks if the payload matches.
	 *
	 * @return list<array{message_id:string, status:string, detail:string}>
	 */
	private static function parse_sparkpost( string $raw ): array {
		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			return [];
		}

		// Bird posts a single { type, timestamp, data } envelope (also accepted on this URL).
		// Always require Standard Webhooks (whsec) — never shared-secret-only for Bird envelopes.
		if ( self::is_bird_payload( $data ) ) {
			return self::parse_bird( $raw, true );
		}

		$items = array_is_list( $data ) ? $data : [ $data ];
		$out   = [];
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$msys  = isset( $item['msys'] ) && is_array( $item['msys'] ) ? $item['msys'] : $item;
			$event = null;
			foreach ( [ 'message_event', 'track_event', 'gen_event', 'unsubscribe_event' ] as $key ) {
				if ( isset( $msys[ $key ] ) && is_array( $msys[ $key ] ) ) {
					$event = $msys[ $key ];
					break;
				}
			}
			if ( ! is_array( $event ) ) {
				continue;
			}

			$type   = sanitize_key( (string) ( $event['type'] ?? '' ) );
			$status = match ( $type ) {
				'delivery' => 'delivered',
				'bounce', 'out_of_band', 'policy_rejection' => 'bounced',
				'spam_complaint' => 'complained',
				'delay' => 'deferred',
				'injection' => 'accepted',
				default => '',
			};
			if ( $status === '' ) {
				continue;
			}

			$message_id = (string) ( $event['message_id'] ?? '' );
			if ( $message_id === '' && isset( $event['rcpt_meta'] ) && is_array( $event['rcpt_meta'] ) ) {
				$message_id = (string) ( $event['rcpt_meta']['message_id'] ?? '' );
			}
			if ( $message_id === '' ) {
				continue;
			}

			$out[] = [
				'message_id' => sanitize_text_field( $message_id ),
				'status'     => $status,
				'detail'     => sanitize_text_field( $type ),
			];
		}

		return $out;
	}

	/**
	 * @param array<string, mixed> $data
	 */
	private static function is_bird_payload( array $data ): bool {
		$type = (string) ( $data['type'] ?? '' );
		return $type !== ''
			&& str_starts_with( $type, 'email.' )
			&& isset( $data['data'] )
			&& is_array( $data['data'] );
	}

	/**
	 * Bird Email Standard Webhooks envelope.
	 *
	 * @link https://bird.com/en-us/docs/guides/webhooks
	 * @link https://bird.com/en-us/docs/guides/email/events
	 *
	 * @return list<array{message_id:string, status:string, detail:string}>
	 */
	private static function parse_bird( string $raw, bool $require_signature ): array {
		$secret = WebhookSecurity::bird_signing_secret();
		if ( $require_signature || ( $secret !== '' && WebhookSecurity::has_standard_webhook_headers() ) ) {
			if ( $secret === '' || ! WebhookSecurity::verify_standard_webhook( $raw, $secret ) ) {
				throw new \RuntimeException( 'Bird webhook signature verification failed.' );
			}
		}

		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) || ! self::is_bird_payload( $data ) ) {
			return [];
		}

		$type   = sanitize_key( str_replace( '.', '_', (string) ( $data['type'] ?? '' ) ) );
		$status = match ( $type ) {
			'email_delivered' => 'delivered',
			'email_bounced' => 'bounced',
			'email_complained' => 'complained',
			'email_deferred' => 'deferred',
			'email_rejected' => 'rejected',
			'email_accepted', 'email_processed' => 'accepted',
			default => '',
		};
		if ( $status === '' ) {
			return [];
		}

		$payload    = is_array( $data['data'] ?? null ) ? $data['data'] : [];
		$message_id = (string) ( $payload['email_id'] ?? '' );
		if ( $message_id === '' ) {
			return [];
		}

		$detail = (string) ( $data['type'] ?? '' );
		if ( $status === 'bounced' ) {
			$bits = array_filter(
				[
					(string) ( $payload['bounce_type'] ?? '' ),
					(string) ( $payload['bounce_code'] ?? '' ),
					(string) ( $payload['bounce_description'] ?? '' ),
				]
			);
			if ( $bits !== [] ) {
				$detail .= ': ' . implode( ' ', $bits );
			}
		}

		return [
			[
				'message_id' => sanitize_text_field( $message_id ),
				'status'     => $status,
				'detail'     => sanitize_text_field( $detail ),
			],
		];
	}

	/**
	 * @param array{message_id:string, status:string, detail:string} $event
	 */
	private static function apply_event( string $provider, array $event ): bool {
		$message_id = trim( (string) ( $event['message_id'] ?? '' ) );
		$status     = sanitize_key( (string) ( $event['status'] ?? '' ) );
		if ( $message_id === '' || $status === '' ) {
			return false;
		}

		if ( ! class_exists( '\EmailLogs\Includes\EmailLogService' ) ) {
			/**
			 * Fires when a webhook delivery event is received and Email Logs is unavailable.
			 *
			 * @param string               $provider
			 * @param array<string,string> $event
			 */
			do_action( 'wp_powersuite_email_delivery_webhook_event', $provider, $event );
			return false;
		}

		return (bool) \EmailLogs\Includes\EmailLogService::update_delivery_status(
			$message_id,
			$status,
			(string) ( $event['detail'] ?? '' ),
			$provider
		);
	}

	/**
	 * @param array<string, mixed> $body
	 */
	private static function respond( int $code, array $body ): void {
		status_header( $code );
		nocache_headers();
		header( 'Content-Type: application/json; charset=utf-8' );
		echo wp_json_encode( $body );
		exit;
	}
}
