<?php
/**
 * Cloud synchronization coordinator.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\Cloud;

use SUBIN\Mail\EmailErrorClassifier;
use SUBIN\Mail\EmailHealthService;
use SUBIN\Sync\SyncEventRepository;

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

/**
 * Handles site connection, heartbeats, and sync event delivery.
 */
class CloudSyncService {
	const HEARTBEAT_HOOK = 'subin_cloud_sync_heartbeat';
	const SYNC_HOOK      = 'subin_cloud_sync_events';
	const CRON_INTERVAL  = 'subin_every_five_minutes';

	/**
	 * Settings repository.
	 *
	 * @var \SUBIN_SettingsRepository
	 */
	private $settings;

	/**
	 * Site identity.
	 *
	 * @var SiteIdentity
	 */
	private $identity;

	/**
	 * Sync events.
	 *
	 * @var SyncEventRepository
	 */
	private $events;

	/**
	 * Email health.
	 *
	 * @var EmailHealthService
	 */
	private $email_health;

	/**
	 * Email error classifier.
	 *
	 * @var EmailErrorClassifier
	 */
	private $error_classifier;

	/**
	 * Stable local HMAC key for privacy-preserving Cloud identifiers.
	 *
	 * @var string
	 */
	private $hash_key = '';

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->settings         = new \SUBIN_SettingsRepository();
		$this->identity         = new SiteIdentity();
		$this->events           = new SyncEventRepository();
		$this->email_health     = new EmailHealthService();
		$this->error_classifier = new EmailErrorClassifier();
	}

	/**
	 * Register runtime hooks.
	 *
	 * @return void
	 */
	public function register() {
		add_filter( 'cron_schedules', array( __CLASS__, 'cron_schedules' ) );
		add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
		add_action( self::HEARTBEAT_HOOK, array( $this, 'send_heartbeat' ) );
		add_action( self::SYNC_HOOK, array( $this, 'sync_pending_events' ) );
		add_action( 'init', array( __CLASS__, 'schedule' ), 5 );
	}

	/**
	 * Register protected REST endpoints used by Cloud polling.
	 *
	 * @return void
	 */
	public function register_rest_routes() {
		register_rest_route(
			'submission-inbox/v1',
			'/cloud/sync',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'rest_cloud_sync' ),
				'permission_callback' => array( $this, 'rest_authorize_cloud' ),
				'args'                => array(
					'site_uuid' => array(
						'type'     => 'string',
						'required' => true,
					),
					'sync_mode' => array(
						'type'    => 'string',
						'default' => 'lead_summary',
					),
					'limit'     => array(
						'type'    => 'integer',
						'default' => 100,
					),
					'since'     => array(
						'type'     => 'string',
						'required' => false,
					),
				),
			)
		);

		register_rest_route(
			'submission-inbox/v1',
			'/cloud/sync/ack',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'rest_cloud_sync_ack' ),
				'permission_callback' => array( $this, 'rest_authorize_cloud' ),
				'args'                => array(
					'site_uuid'     => array(
						'type'     => 'string',
						'required' => true,
					),
					'batch_id'      => array(
						'type'     => 'string',
						'required' => true,
					),
					'claim_token'   => array(
						'type'     => 'string',
						'required' => false,
					),
					'event_ids'     => array(
						'type'     => 'array',
						'required' => false,
						'items'    => array( 'type' => 'integer' ),
					),
					'event_results' => array(
						'type'     => 'array',
						'required' => false,
					),
				),
			)
		);
	}

	/**
	 * Authorize a Cloud polling request.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return true|\WP_Error
	 */
	public function rest_authorize_cloud( \WP_REST_Request $request ) {
		$settings = $this->settings->all();
		$token    = $this->bearer_token( $request );
		$stored   = (string) ( $settings['cloud_sync_site_token'] ?? '' );

		if ( empty( $settings['cloud_sync_enabled'] ) || empty( $settings['cloud_sync_polling_enabled'] ) || '' === $stored ) {
			return new \WP_Error(
				'subin_cloud_polling_disabled',
				__( 'Cloud polling is not enabled for this site.', 'submission-inbox' ),
				array( 'status' => 403 )
			);
		}

		if ( '' === $token || ! hash_equals( $stored, $token ) ) {
			return new \WP_Error(
				'subin_cloud_invalid_token',
				__( 'Invalid Cloud polling token.', 'submission-inbox' ),
				array( 'status' => 401 )
			);
		}

		return true;
	}

	/**
	 * Return health and queued sync events for Cloud polling.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function rest_cloud_sync( \WP_REST_Request $request ) {
		$settings  = $this->settings->all();
		$identity  = $this->identity->get();
		$site_uuid = sanitize_text_field( (string) $request->get_param( 'site_uuid' ) );

		if ( $site_uuid !== $identity['site_uuid'] ) {
			return new \WP_Error(
				'subin_cloud_site_uuid_mismatch',
				__( 'Cloud polling token does not match this WordPress site.', 'submission-inbox' ),
				array( 'status' => 400 )
			);
		}
		if ( $this->note_privacy_migration_pending() ) {
			return new \WP_Error(
				'subin_cloud_note_privacy_migration_pending',
				__( 'InboxMend is finishing a local note privacy migration. Retry Cloud sync shortly.', 'submission-inbox' ),
				array( 'status' => 503 )
			);
		}

		$mode        = $this->polling_sync_mode( $settings, $request->get_param( 'sync_mode' ) );
		$limit       = max( 1, min( 100, absint( $request->get_param( 'limit' ) ) ) );
		$cursor      = $this->decode_snapshot_cursor( (string) $request->get_param( 'since' ) );
		$claim       = $this->events->claim_pending_batch( $limit );
		$events      = is_array( $claim['events'] ?? null ) ? $claim['events'] : array();
		$claim_token = sanitize_text_field( (string) ( $claim['claim_token'] ?? '' ) );
		$payload     = $this->sync_payload( $events, $claim_token );

		$payload['sync_mode'] = $mode;
		$payload['events']    = array_map(
			function ( $event ) use ( $mode ) {
				return $this->map_event_for_cloud( $event, $mode );
			},
			$events
		);

		$remaining = max( 0, $limit - count( $payload['events'] ) );
		$mail_page = $this->empty_snapshot_page(
			absint( $cursor['mail_event_id'] ?? 0 ),
			(string) ( $cursor['mail_event_at'] ?? $cursor['since_at'] ?? '' )
		);
		$lead_page = $this->empty_snapshot_page(
			absint( $cursor['lead_id'] ?? 0 ),
			(string) ( $cursor['lead_at'] ?? $cursor['since_at'] ?? '' )
		);

		if ( $remaining > 0 ) {
			$mail_limit = 'health_only' === $mode ? $remaining : (int) ceil( $remaining / 2 );
			$mail_page  = $this->email_log_snapshot_events_for_cloud(
				$mode,
				$mail_limit,
				absint( $cursor['mail_event_id'] ?? 0 ),
				(string) ( $cursor['mail_event_at'] ?? $cursor['since_at'] ?? '' )
			);
			$payload['events'] = array_merge( $payload['events'], $mail_page['events'] );
			$remaining         = max( 0, $limit - count( $payload['events'] ) );
		}

		if ( 'health_only' !== $mode && $remaining > 0 ) {
			$lead_page = $this->lead_snapshot_events_for_cloud(
				$mode,
				$remaining,
				absint( $cursor['lead_id'] ?? 0 ),
				(string) ( $cursor['lead_at'] ?? $cursor['since_at'] ?? '' )
			);
			if ( is_wp_error( $lead_page ) ) {
				if ( '' !== $claim_token ) {
					$this->events->fail_claim( $claim_token, $lead_page->get_error_message() );
				}

				return $lead_page;
			}
			$payload['events'] = array_merge( $payload['events'], $lead_page['events'] );
			$remaining         = max( 0, $limit - count( $payload['events'] ) );
		}

		if ( $remaining > 0 && ! empty( $mail_page['has_more'] ) ) {
			$extra_mail = $this->email_log_snapshot_events_for_cloud(
				$mode,
				$remaining,
				absint( $mail_page['next_id'] ?? 0 ),
				(string) ( $mail_page['next_at'] ?? '' )
			);
			$payload['events']    = array_merge( $payload['events'], $extra_mail['events'] );
			$mail_page['next_id'] = $extra_mail['next_id'];
			$mail_page['next_at'] = $extra_mail['next_at'];
			$mail_page['has_more'] = $extra_mail['has_more'];
		}

		$next_since = $this->encode_snapshot_cursor(
			absint( $mail_page['next_id'] ?? 0 ),
			(string) ( $mail_page['next_at'] ?? '' ),
			absint( $lead_page['next_id'] ?? 0 ),
			(string) ( $lead_page['next_at'] ?? '' ),
			(string) ( $cursor['since_at'] ?? '' )
		);
		$has_more = count( $events ) >= $limit || ! empty( $mail_page['has_more'] ) || ! empty( $lead_page['has_more'] );

		$heartbeat      = $this->heartbeat_payload();
		$settings_saved = $this->patch_cloud_settings(
			array(
				'cloud_sync_connection_status' => 'connected',
				'cloud_sync_last_heartbeat_at'  => current_time( 'mysql' ),
				'cloud_sync_last_sync_at'       => current_time( 'mysql' ),
				'cloud_sync_last_sync_error'    => '',
			),
			'rest_poll'
		);
		if ( ! is_wp_error( $settings_saved ) ) {
			$this->identity->update_connection_status( 'connected' );
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'site_uuid'       => $identity['site_uuid'],
					'batch_id'        => $payload['batch_id'],
					'claim_token'     => $claim_token,
					'lease_until'     => sanitize_text_field( (string) ( $claim['lease_until'] ?? '' ) ),
					'ack_required'    => '' !== $claim_token,
					'queue_event_ids' => array_map( 'absint', wp_list_pluck( $events, 'id' ) ),
					'sync_mode'       => $mode,
					'queue_count'     => $this->queue_count(),
					'health'          => $heartbeat['email_health'],
					'events'          => $payload['events'],
					'next_since'      => $next_since,
					'has_more'        => $has_more,
				),
			)
		);
	}

	/**
	 * Acknowledge a polling claim after Cloud has durably accepted it.
	 *
	 * Repeated ACKs are idempotent because terminal rows retain the claim token.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function rest_cloud_sync_ack( \WP_REST_Request $request ) {
		$identity  = $this->identity->get();
		$site_uuid = sanitize_text_field( (string) $request->get_param( 'site_uuid' ) );
		if ( $site_uuid !== $identity['site_uuid'] ) {
			return new \WP_Error(
				'subin_cloud_site_uuid_mismatch',
				__( 'Cloud polling token does not match this WordPress site.', 'submission-inbox' ),
				array( 'status' => 400 )
			);
		}

		$batch_id         = sanitize_text_field( (string) $request->get_param( 'batch_id' ) );
		$batch_claim_token = $this->claim_token_from_batch_id( $batch_id );
		$claim_token      = substr(
			(string) preg_replace( '/[^A-Za-z0-9]/', '', (string) $request->get_param( 'claim_token' ) ),
			0,
			64
		);

		if ( '' !== $claim_token && '' !== $batch_claim_token && ! hash_equals( $batch_claim_token, $claim_token ) ) {
			return new \WP_Error(
				'subin_cloud_claim_mismatch',
				__( 'The Cloud sync batch and claim token do not match.', 'submission-inbox' ),
				array( 'status' => 400 )
			);
		}

		$claim_token = '' !== $claim_token ? $claim_token : $batch_claim_token;
		$this->events->release_expired_claims();
		$claimed_rows = $this->events->get_by_claim_token(
			$claim_token,
			array(
				SyncEventRepository::STATUS_PROCESSING,
				SyncEventRepository::STATUS_SENT,
				SyncEventRepository::STATUS_SKIPPED,
			)
		);

		if ( '' === $claim_token || empty( $claimed_rows ) ) {
			return new \WP_Error(
				'subin_cloud_claim_expired',
				__( 'This Cloud sync claim is unknown or has expired.', 'submission-inbox' ),
				array( 'status' => 409 )
			);
		}

		$all_ids       = array_map( 'absint', wp_list_pluck( $claimed_rows, 'id' ) );
		$event_results = $request->get_param( 'event_results' );

		if ( is_array( $event_results ) && ! empty( $event_results ) ) {
			$local_results = $this->apply_sync_event_results(
				array( 'event_results' => $event_results ),
				$this->local_event_id_map( $claimed_rows ),
				$all_ids,
				$claim_token
			);
		} else {
			$event_ids = $request->get_param( 'event_ids' );
			$event_ids = is_array( $event_ids ) ? array_values( array_filter( array_map( 'absint', $event_ids ) ) ) : array();
			$acked      = $this->events->acknowledge_claim( $claim_token, $event_ids );
			$local_results = array(
				'local_sent'    => $acked,
				'local_failed'  => 0,
				'local_skipped' => 0,
			);
		}

		$settings_saved = $this->patch_cloud_settings(
			array(
				'cloud_sync_last_sync_at'       => current_time( 'mysql' ),
				'cloud_sync_last_sync_error'    => '',
				'cloud_sync_connection_status' => 'connected',
			),
			'rest_ack'
		);
		if ( ! is_wp_error( $settings_saved ) ) {
			$this->identity->update_connection_status( 'connected' );
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'batch_id'     => $batch_id,
					'claim_token'  => $claim_token,
					'acknowledged' => absint( $local_results['local_sent'] ?? 0 ) + absint( $local_results['local_skipped'] ?? 0 ),
					'failed'       => absint( $local_results['local_failed'] ?? 0 ),
					'queue_count'  => $this->queue_count(),
				),
			)
		);
	}

	/**
	 * Add cron interval.
	 *
	 * @param array<string,array<string,mixed>> $schedules Schedules.
	 * @return array<string,array<string,mixed>>
	 */
	public static function cron_schedules( array $schedules ) {
		if ( ! isset( $schedules[ self::CRON_INTERVAL ] ) ) {
			$schedules[ self::CRON_INTERVAL ] = array(
				'interval' => 300,
				'display'  => __( 'Every five minutes', 'submission-inbox' ),
			);
		}

		return $schedules;
	}

	/**
	 * Schedule Cloud jobs.
	 *
	 * @return void
	 */
	public static function schedule() {
		add_filter( 'cron_schedules', array( __CLASS__, 'cron_schedules' ) );

		if ( ! wp_next_scheduled( self::HEARTBEAT_HOOK ) ) {
			wp_schedule_event( time() + 300, self::CRON_INTERVAL, self::HEARTBEAT_HOOK );
		}

		if ( ! wp_next_scheduled( self::SYNC_HOOK ) ) {
			wp_schedule_event( time() + 180, self::CRON_INTERVAL, self::SYNC_HOOK );
		}
	}

	/**
	 * Unschedule Cloud jobs.
	 *
	 * @return void
	 */
	public static function unschedule() {
		wp_clear_scheduled_hook( self::HEARTBEAT_HOOK );
		wp_clear_scheduled_hook( self::SYNC_HOOK );
	}

	/**
	 * Connect this site to Cloud.
	 *
	 * @param string $site_key Site connection key.
	 * @param string $requested_mode Requested sync mode.
	 * @param bool   $full_data_opt_in Full data opt-in.
	 * @return true|\WP_Error
	 */
	public function connect( $site_key, $requested_mode, $full_data_opt_in ) {
		$settings       = $this->settings->all();
		$site_key       = sanitize_text_field( trim( (string) $site_key ) );
		$requested_mode = $this->sanitize_sync_mode( $requested_mode );

		if ( '' === $site_key ) {
			return new \WP_Error( 'subin_cloud_missing_site_key', __( 'Site connection key is required.', 'submission-inbox' ) );
		}

		if ( 'full_lead_data' === $requested_mode && ! $full_data_opt_in ) {
			return new \WP_Error( 'subin_cloud_full_data_consent_required', __( 'Full lead data sync requires explicit consent.', 'submission-inbox' ) );
		}

		$identity = $this->identity->get();
		$response = ( new CloudApiClient( $this->api_base_url( $settings ) ) )->claim_site(
			array(
				'site_key'            => $site_key,
				'site_uuid'           => $identity['site_uuid'],
				'site_name'           => sanitize_text_field( get_bloginfo( 'name' ) ),
				'site_url'            => home_url( '/' ),
				'wp_admin_url'        => admin_url(),
				'plugin_version'      => defined( 'SUBIN_PLUGIN_VERSION' ) ? SUBIN_PLUGIN_VERSION : '',
				'wordpress_version'   => get_bloginfo( 'version' ),
				'php_version'         => PHP_VERSION,
				'requested_sync_mode' => $requested_mode,
				'full_data_opt_in'    => (bool) $full_data_opt_in,
			)
		);

		if ( is_wp_error( $response ) ) {
			$this->save_error( $response->get_error_message(), 'error' );
			return $response;
		}

		if ( empty( $response['site_token'] ) ) {
			$error = new \WP_Error( 'subin_cloud_missing_response_token', __( 'Cloud did not return a site token.', 'submission-inbox' ) );
			$this->save_error( $error->get_error_message(), 'error' );
			return $error;
		}

		$settings_saved = $this->patch_cloud_settings(
			array(
				'cloud_sync_endpoint'           => $this->api_base_url( $settings ),
				'cloud_sync_site_key'           => '',
				'cloud_sync_license_key'        => '',
				'cloud_sync_data_mode'          => $requested_mode,
				'cloud_sync_full_data_consent'  => (bool) $full_data_opt_in,
				'cloud_sync_enabled'            => true,
				'cloud_sync_transport'          => 'polling',
				'cloud_sync_polling_enabled'    => ! empty( $response['cloud_polling_enabled'] ),
				'cloud_sync_site_token'         => sanitize_text_field( (string) ( $response['site_token'] ?? '' ) ),
				'cloud_sync_allowed_mode'       => $this->sanitize_sync_mode( $response['current_sync_mode'] ?? $response['allowed_sync_mode'] ?? $requested_mode ),
				'cloud_sync_full_data_enabled'  => ! empty( $response['full_lead_data_enabled'] ),
				'cloud_sync_connection_status'  => 'connected',
				'cloud_sync_site_id'            => absint( $response['site_id'] ?? 0 ),
				'cloud_sync_license_status'     => sanitize_key( (string) ( $response['subscription_status'] ?? $response['license_status'] ?? '' ) ),
				'cloud_sync_license_site_limit' => absint( $response['subscription_site_limit'] ?? $response['license_site_limit'] ?? 0 ),
				'cloud_sync_heartbeat_interval' => max( 300, absint( $response['heartbeat_interval_seconds'] ?? 300 ) ),
				'cloud_sync_last_sync_error'    => '',
			),
			'connect'
		);
		if ( is_wp_error( $settings_saved ) ) {
			return $settings_saved;
		}

		$this->identity->update_connection_status( 'connected' );
		self::schedule();

		return true;
	}

	/**
	 * Validate the current Cloud connection.
	 *
	 * @return true|\WP_Error
	 */
	public function validate_connection() {
		$response = $this->client()->validate_connection( $this->heartbeat_payload() );
		if ( is_wp_error( $response ) ) {
			$this->save_error( $response->get_error_message(), 'error' );
			return $response;
		}

		$changes = array(
			'cloud_sync_connection_status' => 'connected',
			'cloud_sync_last_heartbeat_at'  => current_time( 'mysql' ),
			'cloud_sync_last_sync_error'    => '',
		);
		if ( array_key_exists( 'current_sync_mode', $response ) || array_key_exists( 'allowed_sync_mode', $response ) ) {
			$changes['cloud_sync_allowed_mode'] = $this->sanitize_sync_mode( $response['current_sync_mode'] ?? $response['allowed_sync_mode'] );
		}
		if ( array_key_exists( 'full_lead_data_enabled', $response ) ) {
			$changes['cloud_sync_full_data_enabled'] = ! empty( $response['full_lead_data_enabled'] );
		}
		if ( array_key_exists( 'heartbeat_interval_seconds', $response ) ) {
			$changes['cloud_sync_heartbeat_interval'] = max( 300, absint( $response['heartbeat_interval_seconds'] ) );
		}

		$settings_saved = $this->patch_cloud_settings( $changes, 'validate_connection' );
		if ( is_wp_error( $settings_saved ) ) {
			return $settings_saved;
		}

		$this->identity->update_connection_status( 'connected' );

		return true;
	}

	/**
	 * Send heartbeat to Cloud.
	 *
	 * @return true|\WP_Error
	 */
	public function send_heartbeat() {
		$settings = $this->settings->all();
		if ( 'push' !== sanitize_key( (string) ( $settings['cloud_sync_transport'] ?? 'polling' ) ) ) {
			return true;
		}

		if ( empty( $settings['cloud_sync_enabled'] ) || empty( $settings['cloud_sync_site_token'] ) ) {
			return true;
		}
		if ( $this->note_privacy_migration_pending() ) {
			return $this->empty_sync_summary();
		}

		$response = $this->client()->heartbeat( $this->heartbeat_payload() );
		if ( is_wp_error( $response ) ) {
			$this->save_error( $response->get_error_message(), 'error' );
			return $response;
		}

		$changes = array(
			'cloud_sync_connection_status' => 'connected',
			'cloud_sync_last_heartbeat_at'  => current_time( 'mysql' ),
			'cloud_sync_last_sync_error'    => '',
		);
		if ( array_key_exists( 'current_sync_mode', $response ) || array_key_exists( 'allowed_sync_mode', $response ) ) {
			$changes['cloud_sync_allowed_mode'] = $this->sanitize_sync_mode( $response['current_sync_mode'] ?? $response['allowed_sync_mode'] );
		}
		if ( array_key_exists( 'full_lead_data_enabled', $response ) ) {
			$changes['cloud_sync_full_data_enabled'] = ! empty( $response['full_lead_data_enabled'] );
		}
		if ( array_key_exists( 'heartbeat_interval_seconds', $response ) ) {
			$changes['cloud_sync_heartbeat_interval'] = max( 300, absint( $response['heartbeat_interval_seconds'] ) );
		}

		$settings_saved = $this->patch_cloud_settings( $changes, 'send_heartbeat' );
		if ( is_wp_error( $settings_saved ) ) {
			return $settings_saved;
		}

		$this->identity->update_connection_status( 'connected' );

		return true;
	}

	/**
	 * Sync pending local events.
	 *
	 * @param int $limit Batch limit.
	 * @return array<string,mixed>|true|\WP_Error
	 */
	public function sync_pending_events( $limit = 25 ) {
		$settings = $this->settings->all();
		if ( 'push' !== sanitize_key( (string) ( $settings['cloud_sync_transport'] ?? 'polling' ) ) ) {
			return $this->empty_sync_summary();
		}

		if ( empty( $settings['cloud_sync_enabled'] ) || empty( $settings['cloud_sync_site_token'] ) ) {
			return true;
		}
		if ( $this->note_privacy_migration_pending() ) {
			return $this->empty_sync_summary();
		}

		$claim       = $this->events->claim_pending_batch( $limit );
		$events      = is_array( $claim['events'] ?? null ) ? $claim['events'] : array();
		$claim_token = sanitize_text_field( (string) ( $claim['claim_token'] ?? '' ) );
		if ( empty( $events ) ) {
			return $this->empty_sync_summary();
		}

		$payload      = $this->sync_payload( $events, $claim_token );
		$response     = $this->client()->sync_events( $payload );
		$ids          = array_map( 'absint', wp_list_pluck( $events, 'id' ) );
		$event_id_map = $this->local_event_id_map( $events );

		if ( is_wp_error( $response ) ) {
			$this->events->fail_claim( $claim_token, $response->get_error_message() );
			$this->save_error( $response->get_error_message(), 'error' );
			return $response;
		}

		$local_results = $this->apply_sync_event_results( $response, $event_id_map, $ids, $claim_token );
		$summary       = $this->sync_response_summary( $response, count( $events ), $local_results );
		$warning       = $this->sync_warning_message( $summary );

		$settings_saved = $this->patch_cloud_settings(
			array(
				'cloud_sync_last_sync_at'      => current_time( 'mysql' ),
				'cloud_sync_last_sync_error'   => $warning,
				'cloud_sync_connection_status' => 'connected',
			),
			'sync_pending_events'
		);
		if ( is_wp_error( $settings_saved ) ) {
			return $settings_saved;
		}

		$this->identity->update_connection_status( 'connected' );

		return $summary;
	}

	/**
	 * Pause outbound delivery while legacy deleted-note bodies are reconciled.
	 *
	 * @return bool
	 */
	private function note_privacy_migration_pending() {
		if ( ! class_exists( '\SUBIN_Database' ) ) {
			return true;
		}

		$schema_version = (string) get_option( \SUBIN_Database::OPTION_KEY, '' );

		return '' === $schema_version
			|| version_compare( $schema_version, '1.18.0', '<' )
			|| false !== get_option( \SUBIN_Database::LEGACY_NOTE_DELETE_CURSOR_OPTION, false )
			|| 1 !== absint( get_option( \SUBIN_Database::LEGACY_NOTE_DELETE_COMPLETE_OPTION, 0 ) );
	}

	/**
	 * Rotate Cloud token.
	 *
	 * @return true|\WP_Error
	 */
	public function rotate_token() {
		$response = $this->client()->rotate_token();
		if ( is_wp_error( $response ) ) {
			$this->save_error( $response->get_error_message(), 'error' );
			return $response;
		}

		$settings_saved = $this->patch_cloud_settings(
			array(
				'cloud_sync_site_token'      => sanitize_text_field( (string) ( $response['site_token'] ?? '' ) ),
				'cloud_sync_last_sync_error' => '',
			),
			'rotate_token'
		);
		if ( is_wp_error( $settings_saved ) ) {
			return $settings_saved;
		}

		return true;
	}

	/**
	 * Revoke Cloud token and disconnect locally.
	 *
	 * @return true|\WP_Error
	 */
	public function revoke_token() {
		$settings = $this->settings->all();
		if ( ! empty( $settings['cloud_sync_site_token'] ) ) {
			$response = $this->client()->revoke_token();
			if ( is_wp_error( $response ) ) {
				$this->save_error( $response->get_error_message(), 'error' );
				return $response;
			}
		}

		$settings_saved = $this->patch_cloud_settings(
			array(
				'cloud_sync_enabled'           => false,
				'cloud_sync_site_token'        => '',
				'cloud_sync_connection_status' => 'disconnected',
				'cloud_sync_last_sync_error'   => '',
			),
			'revoke_token'
		);
		if ( is_wp_error( $settings_saved ) ) {
			return $settings_saved;
		}

		$this->identity->update_connection_status( 'disconnected' );

		return true;
	}

	/**
	 * Build heartbeat payload.
	 *
	 * @return array<string,mixed>
	 */
	private function heartbeat_payload() {
		$settings = $this->settings->all();
		$signal   = $this->email_health->current_signal();
		$attempted = absint( $signal['sent'] ?? 0 ) + absint( $signal['failed'] ?? 0 );
		$failed   = absint( $signal['failed'] ?? 0 );
		$state    = sanitize_key( $signal['state'] ?? EmailHealthService::STATE_UNKNOWN );
		$cloud_status = $this->cloud_health_status( $state );

		return array(
			'plugin_version'           => defined( 'SUBIN_PLUGIN_VERSION' ) ? SUBIN_PLUGIN_VERSION : '',
			'wordpress_version'        => get_bloginfo( 'version' ),
			'php_version'              => PHP_VERSION,
			'current_sync_mode'        => $this->effective_sync_mode( $settings ),
			'health_status'            => $cloud_status,
			'last_local_sync_event_id' => (string) $this->events->latest_id(),
			'email_health'             => array(
				'period_start'    => gmdate( 'c', strtotime( '-30 days' ) ),
				'period_end'      => gmdate( 'c' ),
				'sent_count'      => absint( $signal['sent'] ?? 0 ),
				'failed_count'    => $failed,
				'failure_rate'    => $attempted > 0 ? round( $failed / $attempted, 4 ) : null,
				'acceptance_rate' => $signal['acceptance_rate'] ?? null,
				'last_success_at' => ! empty( $signal['last_success_at'] ) ? $signal['last_success_at'] : null,
				'last_failure_at' => ! empty( $signal['last_problem_at'] ) ? $signal['last_problem_at'] : null,
				'health_status'   => $cloud_status,
				'health_state'    => $state,
				'metadata'        => array(
					'queue_count'              => $this->queue_count(),
					'active_issue_count'       => absint( $signal['active_issue_count'] ?? 0 ),
					'recovering_issue_count'   => absint( $signal['recovering_issue_count'] ?? 0 ),
					'affected_source_count'    => absint( $signal['affected_source_count'] ?? 0 ),
					'problem_event_count'      => absint( $signal['problem_event_count'] ?? 0 ),
					'count_is_lower_bound'     => ! empty( $signal['count_is_lower_bound'] ),
					'no_email_generated_count' => absint( $signal['no_email_generated'] ?? 0 ),
					'unknown_count'            => absint( $signal['unknown'] ?? 0 ),
					'sources'                  => (array) ( $signal['sources'] ?? array() ),
				),
			),
		);
	}

	/** Map canonical local states to the stable Cloud status vocabulary. */
	private function cloud_health_status( $state ) {
		$map = array(
			EmailHealthService::STATE_NO_DATA    => 'no_data',
			EmailHealthService::STATE_HEALTHY    => 'healthy',
			EmailHealthService::STATE_RECOVERING => 'recovering',
			EmailHealthService::STATE_DEGRADED   => 'needs_attention',
			EmailHealthService::STATE_CRITICAL   => 'needs_attention',
			EmailHealthService::STATE_UNKNOWN    => 'needs_attention',
		);
		$state = sanitize_key( $state );

		return $map[ $state ] ?? 'needs_attention';
	}

	/**
	 * Build sync batch payload.
	 *
	 * @param array<int,object> $events Events.
	 * @return array<string,mixed>
	 */
	private function sync_payload( array $events, $claim_token = '' ) {
		$settings    = $this->settings->all();
		$identity    = $this->identity->get();
		$mode        = $this->effective_sync_mode( $settings );
		$claim_token = sanitize_text_field( (string) $claim_token );

		return array(
			'site_uuid' => $identity['site_uuid'],
			'batch_id'  => '' !== $claim_token
				? 'subin_claim_' . $claim_token
				: 'subin_' . $identity['site_uuid'] . '_' . time() . '_' . wp_generate_password( 8, false, false ),
			'sync_mode' => $mode,
			'events'    => array_map(
				function ( $event ) use ( $mode ) {
					return $this->map_event_for_cloud( $event, $mode );
				},
				$events
			),
		);
	}

	/**
	 * Build an empty sync summary.
	 *
	 * @return array<string,mixed>
	 */
	private function empty_sync_summary() {
		return array(
			'attempted'              => 0,
			'received'               => 0,
			'duplicates'             => 0,
			'normalized'             => 0,
			'normalization_failures' => 0,
			'local_sent'             => 0,
			'local_failed'           => 0,
			'local_skipped'          => 0,
			'event_results'          => 0,
		);
	}

	/**
	 * Map Cloud plugin_event_id values back to local event ids.
	 *
	 * @param array<int,object> $events Events.
	 * @return array<string,array{id:int,event_type:string}>
	 */
	private function local_event_id_map( array $events ) {
		$map = array();
		foreach ( $events as $event ) {
			$id = absint( $event->id ?? 0 );
			if ( $id > 0 ) {
				$map[ 'subin_' . $id ] = array(
					'id'         => $id,
					'event_type' => $this->sanitize_event_type( (string) ( $event->event_type ?? '' ) ),
				);
			}
		}

		return $map;
	}

	/**
	 * Apply Cloud per-event results to the local sync queue.
	 *
	 * @param array<string,mixed> $response Cloud response data.
	 * @param array<string,array{id:int,event_type:string}> $event_id_map Plugin event id to local event map.
	 * @param array<int,int>      $all_ids All attempted local event ids.
	 * @return array<string,int>
	 */
	private function apply_sync_event_results( array $response, array $event_id_map, array $all_ids, $claim_token = '' ) {
		$results = is_array( $response['event_results'] ?? null ) ? $response['event_results'] : array();
		if ( empty( $results ) ) {
			$this->events->mark_sent( $all_ids, $claim_token );
			return array(
				'local_sent'    => count( $all_ids ),
				'local_failed'  => 0,
				'local_skipped' => 0,
			);
		}

		$sent_ids         = array();
		$failed_by_error  = array();
		$skipped_by_error = array();
		$handled_ids      = array();

		foreach ( $results as $result ) {
			if ( ! is_array( $result ) ) {
				continue;
			}

			$plugin_event_id = sanitize_text_field( (string) ( $result['plugin_event_id'] ?? '' ) );
			if ( ! isset( $event_id_map[ $plugin_event_id ] ) ) {
				continue;
			}

			$local_event   = is_array( $event_id_map[ $plugin_event_id ] ) ? $event_id_map[ $plugin_event_id ] : array();
			$local_id      = absint( $local_event['id'] ?? 0 );
			$event_type    = $this->sanitize_event_type( (string) ( $local_event['event_type'] ?? '' ) );
			$is_deletion   = '.deleted' === substr( $event_type, -8 );
			$status        = sanitize_key( (string) ( $result['status'] ?? '' ) );
			$message       = sanitize_textarea_field( (string) ( $result['message'] ?? '' ) );
			$handled_ids[] = $local_id;

			if ( in_array( $status, array( 'stored', 'normalized', 'duplicate', 'sent', 'accepted' ), true ) ) {
				$sent_ids[] = $local_id;
				continue;
			}

			if ( in_array( $status, array( 'rejected', 'skipped' ), true ) ) {
				$message = '' !== $message ? $message : __( 'Cloud rejected this sync event.', 'submission-inbox' );
				if ( $is_deletion ) {
					$failed_by_error[ $message ][] = $local_id;
				} else {
					$skipped_by_error[ $message ][] = $local_id;
				}
				continue;
			}

			$message = '' !== $message ? $message : sprintf(
				/* translators: %s: Cloud per-event status. */
				__( 'Cloud returned sync event status: %s.', 'submission-inbox' ),
				$status
			);
			$failed_by_error[ $message ][] = $local_id;
		}

		$missing_ids = array_values( array_diff( $all_ids, $handled_ids ) );
		if ( ! empty( $missing_ids ) ) {
			$failed_by_error[ __( 'Cloud did not return a result for this sync event.', 'submission-inbox' ) ] = $missing_ids;
		}

		if ( ! empty( $sent_ids ) ) {
			$this->events->mark_sent( $sent_ids, $claim_token );
		}

		foreach ( $failed_by_error as $error => $ids ) {
			$this->events->mark_failed( $ids, $error, $claim_token );
		}

		foreach ( $skipped_by_error as $error => $ids ) {
			$this->events->mark_skipped( $ids, $error, $claim_token );
		}

		return array(
			'local_sent'    => count( $sent_ids ),
			'local_failed'  => array_sum( array_map( 'count', $failed_by_error ) ),
			'local_skipped' => array_sum( array_map( 'count', $skipped_by_error ) ),
		);
	}

	/**
	 * Build a normalized sync summary from Cloud response data.
	 *
	 * @param array<string,mixed> $response Cloud response data.
	 * @param int                 $attempted Attempted local event count.
	 * @param array<string,int>   $local_results Local queue result counts.
	 * @return array<string,mixed>
	 */
	private function sync_response_summary( array $response, $attempted, array $local_results ) {
		return array(
			'attempted'              => absint( $attempted ),
			'received'               => absint( $response['received'] ?? 0 ),
			'duplicates'             => absint( $response['duplicates'] ?? 0 ),
			'normalized'             => absint( $response['normalized'] ?? 0 ),
			'normalization_failures' => absint( $response['normalization_failures'] ?? 0 ),
			'local_sent'             => absint( $local_results['local_sent'] ?? 0 ),
			'local_failed'           => absint( $local_results['local_failed'] ?? 0 ),
			'local_skipped'          => absint( $local_results['local_skipped'] ?? 0 ),
			'event_results'          => is_array( $response['event_results'] ?? null ) ? count( $response['event_results'] ) : 0,
		);
	}

	/**
	 * Build a persisted warning message for partial sync failures.
	 *
	 * @param array<string,mixed> $summary Sync summary.
	 * @return string
	 */
	private function sync_warning_message( array $summary ) {
		if ( empty( $summary['normalization_failures'] ) && empty( $summary['local_failed'] ) && empty( $summary['local_skipped'] ) ) {
			return '';
		}

		return sprintf(
			/* translators: 1: normalization failures, 2: local failed count, 3: local skipped count. */
			__( 'Cloud sync completed with issues: %1$d normalization failures, %2$d local failed events, %3$d skipped events.', 'submission-inbox' ),
			absint( $summary['normalization_failures'] ?? 0 ),
			absint( $summary['local_failed'] ?? 0 ),
			absint( $summary['local_skipped'] ?? 0 )
		);
	}

	/**
	 * Map one local event to Cloud contract.
	 *
	 * @param object $event Event row.
	 * @param string $mode Sync mode.
	 * @return array<string,mixed>
	 */
	private function map_event_for_cloud( $event, $mode ) {
		$payload = json_decode( (string) ( $event->payload_json ?? '' ), true );
		if ( ! is_array( $payload ) ) {
			$payload = array();
		}

		$event_type = $this->sanitize_event_type( (string) ( $event->event_type ?? '' ) );
		if ( in_array( $event_type, array( 'lead.deleted', 'email.deleted' ), true ) ) {
			$id_key = 'lead.deleted' === $event_type ? 'external_lead_id' : 'external_event_id';
			if ( absint( $payload[ $id_key ] ?? 0 ) <= 0 ) {
				$payload[ $id_key ] = absint( $event->object_id ?? 0 );
			}
			if ( empty( $payload['deleted_at'] ) ) {
				$payload['deleted_at'] = sanitize_text_field( (string) ( $event->created_at ?? '' ) );
			}
		}

		return array(
			'plugin_event_id' => 'subin_' . absint( $event->id ),
			'event_type'      => $event_type,
			'occurred_at'     => $this->rfc3339( $event->created_at ?? '' ),
			'payload'         => $this->cloud_payload_for_mode( $event_type, $payload, $mode ),
		);
	}

	/**
	 * Convert stored payload to the selected sync mode.
	 *
	 * @param string              $event_type Event type.
	 * @param array<string,mixed> $payload Payload.
	 * @param string              $mode Sync mode.
	 * @return array<string,mixed>
	 */
	private function cloud_payload_for_mode( $event_type, array $payload, $mode ) {
		if ( in_array( $event_type, array( 'lead.deleted', 'email.deleted' ), true ) ) {
			return $this->deletion_payload( $event_type, $payload );
		}
		if ( 0 === strpos( $event_type, 'email.' ) ) {
			return $this->email_payload( $event_type, $payload, $mode );
		}

		$lead               = is_array( $payload['lead'] ?? null ) ? $payload['lead'] : $payload;
		$is_note_event      = in_array( $event_type, array( 'lead.note_added', 'lead.note_updated', 'lead.note_deleted' ), true );
		$note               = is_array( $payload['note'] ?? null ) ? $payload['note'] : array();
		$has_versioned_note = $is_note_event && absint( $note['id'] ?? 0 ) > 0 && absint( $note['note_version'] ?? 0 ) > 0;
		if ( 'health_only' === $mode ) {
			$health = array(
				'external_lead_id' => (string) ( $lead['id'] ?? '' ),
				'event_type'       => $event_type,
				'status_snapshot'  => sanitize_key( (string) ( $lead['status'] ?? '' ) ),
				'workflow_stage'   => sanitize_key( (string) ( $lead['workflow_status'] ?? '' ) ),
				'email_status'     => sanitize_key( (string) ( $lead['email_status'] ?? '' ) ),
				'admin_notification_status' => sanitize_key( (string) ( $lead['email_status'] ?? '' ) ),
				'visitor_auto_reply_status' => sanitize_key( (string) ( $lead['auto_reply_status'] ?? 'not_configured' ) ),
			);

			if ( $has_versioned_note ) {
				$health['note'] = $this->note_event_payload( $note, $mode );
			}

			return $health;
		}

		$summary = array(
			'external_lead_id' => (string) ( $lead['id'] ?? '' ),
			'source_form'      => sanitize_text_field( (string) ( $lead['form_name'] ?? $lead['form_id'] ?? '' ) ),
			'submitted_at'     => $this->rfc3339( $lead['submitted_at'] ?? '' ),
			'status_snapshot'  => sanitize_key( (string) ( $lead['status'] ?? '' ) ),
			'workflow_stage'   => sanitize_key( (string) ( $lead['workflow_status'] ?? '' ) ),
			'email_status'     => sanitize_key( (string) ( $lead['email_status'] ?? '' ) ),
			'admin_notification_status' => sanitize_key( (string) ( $lead['email_status'] ?? '' ) ),
			'visitor_auto_reply_status' => sanitize_key( (string) ( $lead['auto_reply_status'] ?? 'not_configured' ) ),
			'title'            => sanitize_text_field( (string) ( $lead['form_name'] ?? __( 'Lead', 'submission-inbox' ) ) ),
			'email_hash'       => $this->resolved_hash( $lead['customer_email'] ?? '', $lead['customer_email_hash'] ?? '' ),
			'email_preview'    => sanitize_text_field( (string) ( $lead['customer_email_preview'] ?? $this->email_preview( $lead['customer_email'] ?? '' ) ) ),
			'name_preview'     => $this->name_preview( $lead['customer_name'] ?? '' ),
		);

		if ( 'full_lead_data' !== $mode ) {
			if ( $has_versioned_note ) {
				$summary['note'] = $this->note_event_payload( $note, $mode );
			}

			return $summary;
		}

		$full = array_merge(
			$summary,
			array(
				'email'  => sanitize_email( (string) ( $lead['customer_email'] ?? '' ) ),
				'name'   => sanitize_text_field( (string) ( $lead['customer_name'] ?? '' ) ),
				'phone'  => sanitize_text_field( (string) ( $lead['customer_phone'] ?? '' ) ),
				'fields' => $this->full_fields( $lead['fields'] ?? array() ),
			)
		);
		if ( $has_versioned_note ) {
			$full['note'] = $this->note_event_payload( $note, $mode );
		} else {
			$full['notes'] = $this->full_notes( $lead['notes'] ?? array() );
		}

		return $full;
	}

	/**
	 * Map a deletion tombstone without deriving or forwarding private fields.
	 *
	 * The local queue already stores an allowlisted payload. Rebuilding the
	 * contract here provides a second privacy boundary for legacy or malformed
	 * rows and makes the wire shape independent of the selected data mode.
	 *
	 * @param string              $event_type Deletion event type.
	 * @param array<string,mixed> $payload Stored payload.
	 * @return array<string,mixed>
	 */
	private function deletion_payload( $event_type, array $payload ) {
		$id_key  = 'lead.deleted' === $event_type ? 'external_lead_id' : 'external_event_id';
		$reason  = sanitize_key( (string) ( $payload['reason'] ?? '' ) );
		$allowed = array( 'retention', 'manual', 'privacy_request', 'parent_deleted' );

		return array(
			'contract_version' => 1,
			$id_key            => (string) absint( $payload[ $id_key ] ?? 0 ),
			'deleted'          => true,
			'deleted_at'       => $this->rfc3339( $payload['deleted_at'] ?? '' ),
			'reason'           => in_array( $reason, $allowed, true ) ? $reason : 'retention',
		);
	}

	/**
	 * Build email event payload.
	 *
	 * @param string              $event_type Event type.
	 * @param array<string,mixed> $payload Payload.
	 * @param string              $mode Sync mode.
	 * @return array<string,mixed>
	 */
	private function email_payload( $event_type, array $payload, $mode ) {
		$email_status = sanitize_key( (string) ( $payload['status'] ?? str_replace( 'email.', '', $event_type ) ) );
		$data         = array(
			'external_event_id' => (string) ( $payload['event_id'] ?? '' ),
			'event_type'        => $email_status,
			'transport'         => 'wordpress',
			'source'            => sanitize_key( (string) ( $payload['source'] ?? '' ) ),
			'status'            => $email_status,
			'lead_id'           => absint( $payload['lead_id'] ?? 0 ),
			'recipient_hash'    => $this->resolved_hash( $payload['to_email'] ?? '', $payload['to_email_hash'] ?? '' ),
			'to_email_hash'     => $this->resolved_hash( $payload['to_email'] ?? '', $payload['to_email_hash'] ?? '' ),
			'subject_hash'      => $this->resolved_hash( $payload['subject'] ?? '', $payload['subject_hash'] ?? '' ),
			'error_code'        => sanitize_key( (string) ( $payload['error_code'] ?? '' ) ),
			'error_message'     => $this->error_classifier->sanitize_technical_error( (string) ( $payload['error_message'] ?? '' ) ),
			'created_at'        => sanitize_text_field( (string) ( $payload['created_at'] ?? '' ) ),
			'message_hash'      => sanitize_text_field( (string) ( $payload['message_hash'] ?? '' ) ),
		);

		if ( 'full_lead_data' === $mode ) {
			$data['recipient'] = sanitize_text_field( (string) ( $payload['to_email'] ?? '' ) );
			$data['subject']   = sanitize_text_field( (string) ( $payload['subject'] ?? '' ) );
		}

		return $data;
	}

	/**
	 * Build an Email Log snapshot page for Cloud polling.
	 *
	 * @param string $mode Sync mode.
	 * @param int    $limit Maximum snapshots.
	 * @param int    $after_id Cursor id.
	 * @param string $after_at Cursor updated datetime.
	 * @return array{events:array<int,array<string,mixed>>,next_id:int,next_at:string,has_more:bool}|\WP_Error
	 */
	private function email_log_snapshot_events_for_cloud( $mode, $limit, $after_id = 0, $after_at = '' ) {
		$page = $this->empty_snapshot_page( $after_id, $after_at );
		if ( $limit <= 0 ) {
			return $page;
		}

		global $wpdb;

		$table       = $wpdb->prefix . 'subin_mail_events';
		$sync_table  = $wpdb->prefix . 'subin_sync_events';
		$limit       = max( 1, min( 100, absint( $limit ) ) );
		$fetch_limit = $limit + 1;
		$after_id    = absint( $after_id );
		$after_at    = $this->cursor_datetime( $after_at );

		if ( '' !== $after_at ) {
			$sql = "SELECT mail_rows.id, mail_rows.lead_id, mail_rows.source, mail_rows.to_email, mail_rows.subject, mail_rows.message_hash, mail_rows.status, mail_rows.error_message, mail_rows.created_at, mail_rows.updated_at
				FROM {$table} mail_rows
				WHERE (mail_rows.updated_at > %s OR (mail_rows.updated_at = %s AND mail_rows.id > %d))
					AND NOT EXISTS (
						SELECT 1 FROM {$sync_table} deletion_rows
						WHERE deletion_rows.object_type = 'mail_event'
							AND deletion_rows.object_id = CAST(mail_rows.id AS CHAR)
							AND deletion_rows.event_type = 'email.deleted'
					)
				ORDER BY mail_rows.updated_at ASC, mail_rows.id ASC
				LIMIT %d";
			$rows = $wpdb->get_results(
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Values use placeholders; table names come only from the trusted WordPress prefix.
				$wpdb->prepare( $sql, $after_at, $after_at, $after_id, $fetch_limit ),
				ARRAY_A
			); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		} else {
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT mail_rows.id, mail_rows.lead_id, mail_rows.source, mail_rows.to_email, mail_rows.subject, mail_rows.message_hash, mail_rows.status, mail_rows.error_message, mail_rows.created_at, mail_rows.updated_at
					FROM {$table} mail_rows
					WHERE mail_rows.id > %d
						AND NOT EXISTS (
							SELECT 1 FROM {$sync_table} deletion_rows
							WHERE deletion_rows.object_type = 'mail_event'
								AND deletion_rows.object_id = CAST(mail_rows.id AS CHAR)
								AND deletion_rows.event_type = 'email.deleted'
						)
					ORDER BY mail_rows.id ASC
					LIMIT %d",
					$after_id,
					$fetch_limit
				),
				ARRAY_A
			); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}

		$rows             = is_array( $rows ) ? $rows : array();
		$page['has_more'] = count( $rows ) > $limit;
		$rows             = array_slice( $rows, 0, $limit );

		foreach ( $rows as $row ) {
			$status = sanitize_key( (string) ( $row['status'] ?? 'unknown' ) );
			switch ( $status ) {
				case 'sent':
					$event_type = 'email.sent';
					break;
				case 'failed':
					$event_type = 'email.failed';
					break;
				case 'no_email_generated':
					$event_type = 'email.no_email_generated';
					break;
				default:
					$event_type = 'email.unknown';
					break;
			}

			$updated_at = sanitize_text_field( (string) ( $row['updated_at'] ?? $row['created_at'] ?? '' ) );
			$payload    = array(
				'event_id'      => absint( $row['id'] ?? 0 ),
				'lead_id'       => absint( $row['lead_id'] ?? 0 ),
				'source'        => sanitize_key( (string) ( $row['source'] ?? '' ) ),
				'status'        => $status,
				'to_email'      => sanitize_textarea_field( $row['to_email'] ?? '' ),
				'subject'       => sanitize_text_field( $row['subject'] ?? '' ),
				'message_hash'  => sanitize_text_field( $row['message_hash'] ?? '' ),
				'error_message' => sanitize_textarea_field( $row['error_message'] ?? '' ),
				'created_at'    => sanitize_text_field( $row['created_at'] ?? '' ),
			);

			$page['events'][] = array(
				'plugin_event_id' => 'subin_mail_snapshot_' . absint( $row['id'] ?? 0 ) . '_' . $status . '_' . substr( md5( $updated_at ), 0, 10 ),
				'event_type'      => $event_type,
				'occurred_at'     => $this->rfc3339( $row['created_at'] ?? '' ),
				'payload'         => $this->email_payload( $event_type, $payload, $mode ),
			);
			$page['next_id'] = absint( $row['id'] ?? 0 );
			$page['next_at'] = $updated_at;
		}

		return $page;
	}

	/**
	 * Build a lead snapshot page for Cloud polling.
	 *
	 * @param string $mode Sync mode.
	 * @param int    $limit Maximum snapshots.
	 * @param int    $after_id Cursor id.
	 * @param string $after_at Cursor updated datetime.
	 * @return array{events:array<int,array<string,mixed>>,next_id:int,next_at:string,has_more:bool}
	 */
	private function lead_snapshot_events_for_cloud( $mode, $limit, $after_id = 0, $after_at = '' ) {
		$page = $this->empty_snapshot_page( $after_id, $after_at );
		if ( 'health_only' === $mode || $limit <= 0 ) {
			return $page;
		}

		global $wpdb;

		$leads_table = $wpdb->prefix . 'subin_leads';
		$limit       = max( 1, min( 100, absint( $limit ) ) );
		$fetch_limit = $limit + 1;
		$after_id    = absint( $after_id );
		$after_at    = $this->cursor_datetime( $after_at );

		if ( '' !== $after_at ) {
			$sql = "SELECT * FROM {$leads_table}
				WHERE updated_at > %s OR (updated_at = %s AND id > %d)
				ORDER BY updated_at ASC, id ASC
				LIMIT %d";
			$leads = $wpdb->get_results(
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Values use placeholders; the table name comes only from the trusted WordPress prefix.
				$wpdb->prepare( $sql, $after_at, $after_at, $after_id, $fetch_limit ),
				ARRAY_A
			); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		} else {
			$leads = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT * FROM {$leads_table}
					WHERE id > %d
					ORDER BY id ASC
					LIMIT %d",
					$after_id,
					$fetch_limit
				),
				ARRAY_A
			); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}

		$leads            = is_array( $leads ) ? $leads : array();
		$page['has_more'] = count( $leads ) > $limit;
		$leads            = array_slice( $leads, 0, $limit );
		$lead_ids         = array_values( array_filter( array_map( 'absint', wp_list_pluck( $leads, 'id' ) ) ) );
		$fields_by_lead = 'full_lead_data' === $mode ? $this->lead_fields_for_cloud_ids( $lead_ids ) : array();
		if ( is_wp_error( $fields_by_lead ) ) {
			return $fields_by_lead;
		}
		$notes_by_lead = array();
		if ( 'full_lead_data' === $mode ) {
			$marked = $this->mark_full_snapshot_note_footprints( $lead_ids );
			if ( is_wp_error( $marked ) ) {
				return $marked;
			}
			$notes_by_lead = $this->lead_notes_for_cloud_ids( $lead_ids );
			if ( is_wp_error( $notes_by_lead ) ) {
				return $notes_by_lead;
			}
		}

		foreach ( $leads as $lead ) {
			$lead    = is_array( $lead ) ? $lead : array();
			$lead_id = absint( $lead['id'] ?? 0 );

			if ( 'full_lead_data' === $mode ) {
				$lead['fields'] = $fields_by_lead[ $lead_id ] ?? array();
				$lead['notes']  = $notes_by_lead[ $lead_id ] ?? array();
			}

			$updated_at = sanitize_text_field( (string) ( $lead['updated_at'] ?? $lead['submitted_at'] ?? '' ) );
			$page['events'][] = array(
				'plugin_event_id' => 'subin_lead_snapshot_' . $lead_id . '_' . $mode . '_' . substr( md5( $updated_at ), 0, 10 ),
				'event_type'      => 'lead.snapshot',
				'occurred_at'     => $this->rfc3339( $lead['updated_at'] ?? $lead['submitted_at'] ?? '' ),
				'payload'         => $this->cloud_payload_for_mode( 'lead.snapshot', $lead, $mode ),
			);
			$page['next_id'] = $lead_id;
			$page['next_at'] = $updated_at;
		}

		return $page;
	}

	/**
	 * Bulk-load full lead fields for a snapshot page.
	 *
	 * @param array<int,int> $lead_ids Lead ids.
	 * @return array<int,array<int,array<string,mixed>>>|\WP_Error
	 */
	private function lead_fields_for_cloud_ids( array $lead_ids ) {
		global $wpdb;

		$lead_ids = array_values( array_filter( array_map( 'absint', $lead_ids ) ) );
		if ( empty( $lead_ids ) ) {
			return array();
		}

		$table        = $wpdb->prefix . 'subin_lead_fields';
		$placeholders = implode( ',', array_fill( 0, count( $lead_ids ), '%d' ) );
		$wpdb->last_error = '';
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT lead_id, field_key, field_label, field_value, is_sensitive
				FROM {$table}
				WHERE lead_id IN ({$placeholders})
				ORDER BY lead_id ASC, id ASC",
				...$lead_ids
			),
			ARRAY_A
		); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		if ( ! is_array( $rows ) || '' !== (string) $wpdb->last_error ) {
			return new \WP_Error( 'subin_cloud_lead_fields_read_failed', __( 'InboxMend could not prepare the lead fields for Cloud sync.', 'submission-inbox' ), array( 'status' => 503 ) );
		}
		$grouped = array();

		foreach ( $rows as $row ) {
			$lead_id      = absint( $row['lead_id'] ?? 0 );
			$is_sensitive = ! empty( $row['is_sensitive'] );
			$grouped[ $lead_id ][] = array(
				'key'          => sanitize_text_field( (string) ( $row['field_key'] ?? '' ) ),
				'label'        => sanitize_text_field( (string) ( $row['field_label'] ?? '' ) ),
				'value'        => $is_sensitive ? '[redacted]' : sanitize_textarea_field( (string) ( $row['field_value'] ?? '' ) ),
				'is_sensitive' => $is_sensitive,
			);
		}

		return $grouped;
	}

	/**
	 * Bulk-load full lead notes for a snapshot page.
	 *
	 * @param array<int,int> $lead_ids Lead ids.
	 * @return array<int,array<int,array<string,mixed>>>|\WP_Error
	 */
	private function lead_notes_for_cloud_ids( array $lead_ids ) {
		global $wpdb;

		$lead_ids = array_values( array_filter( array_map( 'absint', $lead_ids ) ) );
		if ( empty( $lead_ids ) ) {
			return array();
		}

		$table        = $wpdb->prefix . 'subin_lead_notes';
		$placeholders = implode( ',', array_fill( 0, count( $lead_ids ), '%d' ) );
		$wpdb->last_error = '';
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT lead_id, id, note, note_version, created_at, updated_at
				FROM {$table}
				WHERE lead_id IN ({$placeholders})
					AND sync_footprint = 1
				ORDER BY lead_id ASC, created_at ASC, id ASC",
				...$lead_ids
			),
			ARRAY_A
		); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		if ( ! is_array( $rows ) || '' !== (string) $wpdb->last_error ) {
			return new \WP_Error( 'subin_cloud_lead_notes_read_failed', __( 'InboxMend could not prepare the lead notes for Cloud sync.', 'submission-inbox' ), array( 'status' => 503 ) );
		}
		$grouped = array();

		foreach ( $rows as $row ) {
			$lead_id = absint( $row['lead_id'] ?? 0 );
			$grouped[ $lead_id ][] = array(
				'id'           => absint( $row['id'] ?? 0 ),
				'note'         => sanitize_textarea_field( (string) ( $row['note'] ?? '' ) ),
				'note_version' => max( 1, absint( $row['note_version'] ?? 1 ) ),
				'created_at'   => sanitize_text_field( (string) ( $row['created_at'] ?? '' ) ),
				'updated_at'   => sanitize_text_field( (string) ( $row['updated_at'] ?? $row['created_at'] ?? '' ) ),
			);
		}

		return $grouped;
	}

	/**
	 * Persist a remote footprint before any full-data snapshot exposes note text.
	 *
	 * The subsequent snapshot query reads only marked rows. A note inserted after
	 * this update is therefore deferred until the lead's next updated-at cursor,
	 * while a concurrent delete sees the footprint and stages its tombstone.
	 *
	 * @param array<int,int> $lead_ids Lead ids in this snapshot page.
	 * @return true|\WP_Error
	 */
	private function mark_full_snapshot_note_footprints( array $lead_ids ) {
		global $wpdb;

		$lead_ids = array_values( array_filter( array_map( 'absint', $lead_ids ) ) );
		if ( empty( $lead_ids ) ) {
			return true;
		}

		$table        = $wpdb->prefix . 'subin_lead_notes';
		$placeholders = implode( ',', array_fill( 0, count( $lead_ids ), '%d' ) );
		$marked       = $wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table}
				SET sync_footprint = 1
				WHERE lead_id IN ({$placeholders}) AND sync_footprint = 0",
				...$lead_ids
			)
		); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		if ( false === $marked ) {
			return new \WP_Error( 'subin_cloud_note_footprint_failed', __( 'InboxMend could not safely prepare lead notes for Cloud sync.', 'submission-inbox' ), array( 'status' => 503 ) );
		}

		return true;
	}

	/**
	 * Build an empty snapshot page.
	 *
	 * @param int    $next_id Current cursor id.
	 * @param string $next_at Current cursor datetime.
	 * @return array{events:array<int,array<string,mixed>>,next_id:int,next_at:string,has_more:bool}
	 */
	private function empty_snapshot_page( $next_id = 0, $next_at = '' ) {
		return array(
			'events'   => array(),
			'next_id'  => absint( $next_id ),
			'next_at'  => $this->cursor_datetime( $next_at ),
			'has_more' => false,
		);
	}

	/**
	 * Decode an opaque or numeric snapshot cursor.
	 *
	 * A numeric `since` remains supported as a shared id cursor. Date values are
	 * treated as the initial `(updated_at,id)` lower bound for both snapshots.
	 *
	 * @param string $since Cursor.
	 * @return array{mail_event_id:int,mail_event_at:string,lead_id:int,lead_at:string,since_at:string}
	 */
	private function decode_snapshot_cursor( $since ) {
		$cursor = array(
			'mail_event_id' => 0,
			'mail_event_at' => '',
			'lead_id'       => 0,
			'lead_at'       => '',
			'since_at'      => '',
		);
		$since  = trim( sanitize_text_field( (string) $since ) );
		if ( '' === $since ) {
			return $cursor;
		}

		if ( ctype_digit( $since ) ) {
			$cursor['mail_event_id'] = absint( $since );
			$cursor['lead_id']       = absint( $since );
			return $cursor;
		}

		$encoded = 0 === strpos( $since, 'v1.' ) ? substr( $since, 3 ) : $since;
		$encoded = strtr( $encoded, '-_', '+/' );
		$padding = strlen( $encoded ) % 4;
		if ( $padding > 0 ) {
			$encoded .= str_repeat( '=', 4 - $padding );
		}
		$decoded = base64_decode( $encoded, true );
		$data    = is_string( $decoded ) ? json_decode( $decoded, true ) : null;

		if ( is_array( $data ) ) {
			$cursor['mail_event_id'] = absint( $data['m_id'] ?? 0 );
			$cursor['mail_event_at'] = $this->cursor_datetime( $data['m_at'] ?? '' );
			$cursor['lead_id']       = absint( $data['l_id'] ?? 0 );
			$cursor['lead_at']       = $this->cursor_datetime( $data['l_at'] ?? '' );
			$cursor['since_at']      = $this->cursor_datetime( $data['since'] ?? '' );
			return $cursor;
		}

		$cursor['since_at']      = $this->cursor_datetime( $since );
		$cursor['mail_event_at'] = $cursor['since_at'];
		$cursor['lead_at']       = $cursor['since_at'];

		return $cursor;
	}

	/**
	 * Encode snapshot cursors for the next polling request.
	 *
	 * @param int    $mail_event_id Mail event id.
	 * @param string $mail_event_at Mail event updated datetime.
	 * @param int    $lead_id Lead id.
	 * @param string $lead_at Lead updated datetime.
	 * @param string $since_at Initial since datetime.
	 * @return string
	 */
	private function encode_snapshot_cursor( $mail_event_id, $mail_event_at, $lead_id, $lead_at, $since_at = '' ) {
		$encoded = wp_json_encode(
			array(
				'm_id'  => absint( $mail_event_id ),
				'm_at'  => $this->cursor_datetime( $mail_event_at ),
				'l_id'  => absint( $lead_id ),
				'l_at'  => $this->cursor_datetime( $lead_at ),
				'since' => $this->cursor_datetime( $since_at ),
			)
		);

		if ( ! is_string( $encoded ) ) {
			return '';
		}

		return 'v1.' . rtrim( strtr( base64_encode( $encoded ), '+/', '-_' ), '=' );
	}

	/**
	 * Normalize a cursor datetime.
	 *
	 * @param mixed $value Date value.
	 * @return string
	 */
	private function cursor_datetime( $value ) {
		$value = sanitize_text_field( (string) $value );
		if ( '' === $value ) {
			return '';
		}

		$timezone = function_exists( 'wp_timezone' ) ? wp_timezone() : new \DateTimeZone( 'UTC' );

		if ( preg_match( '/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?$/', $value ) ) {
			$normalized = str_replace( 'T', ' ', $value );
			$format     = 10 === strlen( $normalized ) ? '!Y-m-d' : '!Y-m-d H:i:s';
			$date       = \DateTimeImmutable::createFromFormat( $format, $normalized, $timezone );
			$errors     = \DateTimeImmutable::getLastErrors();

			if ( ! $date || ( is_array( $errors ) && ( $errors['warning_count'] > 0 || $errors['error_count'] > 0 ) ) ) {
				return '';
			}

			return $date->format( 'Y-m-d H:i:s' );
		}

		try {
			$date = new \DateTimeImmutable( $value );
		} catch ( \Exception $exception ) {
			unset( $exception );
			return '';
		}

		return $date->setTimezone( $timezone )->format( 'Y-m-d H:i:s' );
	}

	/**
	 * Extract a claim token embedded in a polling batch id.
	 *
	 * @param string $batch_id Batch id.
	 * @return string
	 */
	private function claim_token_from_batch_id( $batch_id ) {
		$prefix   = 'subin_claim_';
		$batch_id = sanitize_text_field( (string) $batch_id );
		if ( 0 !== strpos( $batch_id, $prefix ) ) {
			return '';
		}

		return substr( (string) preg_replace( '/[^A-Za-z0-9]/', '', substr( $batch_id, strlen( $prefix ) ) ), 0, 64 );
	}

	/**
	 * Count actionable and currently leased queue rows.
	 *
	 * @return int
	 */
	private function queue_count() {
		$counts = $this->events->count_all_by_statuses();

		return absint( $counts[ SyncEventRepository::STATUS_PENDING ] ?? 0 )
			+ absint( $counts[ SyncEventRepository::STATUS_FAILED ] ?? 0 )
			+ absint( $counts[ SyncEventRepository::STATUS_PROCESSING ] ?? 0 );
	}

	/**
	 * Extract bearer token from REST request headers.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return string
	 */
	private function bearer_token( \WP_REST_Request $request ) {
		$site_token = (string) $request->get_header( 'x-si-site-token' );
		if ( '' !== $site_token ) {
			return sanitize_text_field( trim( $site_token ) );
		}

		$authorization = (string) $request->get_header( 'authorization' );

		if ( '' === $authorization && isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
			$authorization = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
		}

		if ( '' === $authorization && isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
			$authorization = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
		}

		if ( preg_match( '/Bearer\s+(.+)/i', $authorization, $matches ) ) {
			return sanitize_text_field( trim( (string) $matches[1] ) );
		}

		return '';
	}

	/**
	 * Resolve sync mode for Cloud polling without exceeding local consent.
	 *
	 * @param array<string,mixed> $settings Settings.
	 * @param mixed               $requested_mode Requested mode.
	 * @return string
	 */
	private function polling_sync_mode( array $settings, $requested_mode ) {
		$allowed = $this->effective_sync_mode( $settings );
		$requested = $this->sanitize_sync_mode( $requested_mode );
		$ranks = array(
			'health_only'    => 1,
			'lead_summary'   => 2,
			'full_lead_data' => 3,
		);

		return $ranks[ $requested ] <= $ranks[ $allowed ] ? $requested : $allowed;
	}

	/**
	 * Current effective sync mode.
	 *
	 * @param array<string,mixed>|null $settings Settings.
	 * @return string
	 */
	private function effective_sync_mode( ?array $settings = null ) {
		$settings    = is_array( $settings ) ? $settings : $this->settings->all();
		$requested   = $this->sanitize_sync_mode( $settings['cloud_sync_data_mode'] ?? 'lead_summary' );
		$allowed_raw = sanitize_key( (string) ( $settings['cloud_sync_allowed_mode'] ?? '' ) );
		$allowed     = in_array( $allowed_raw, array( 'health_only', 'lead_summary', 'full_lead_data' ), true ) ? $allowed_raw : $requested;
		$ranks       = array(
			'health_only'    => 1,
			'lead_summary'   => 2,
			'full_lead_data' => 3,
		);
		$mode = $ranks[ $requested ] <= $ranks[ $allowed ] ? $requested : $allowed;

		if ( 'full_lead_data' === $mode && ( empty( $settings['cloud_sync_full_data_consent'] ) || empty( $settings['cloud_sync_full_data_enabled'] ) ) ) {
			return 'lead_summary';
		}

		return $mode;
	}

	/**
	 * Get API client from settings.
	 *
	 * @return CloudApiClient
	 */
	private function client() {
		$settings = $this->settings->all();

		return new CloudApiClient( $this->api_base_url( $settings ), $settings['cloud_sync_site_token'] ?? '' );
	}

	/**
	 * Resolve the Cloud plugin API base URL.
	 *
	 * @param array<string,mixed>|null $settings Settings.
	 * @return string
	 */
	private function api_base_url( ?array $settings = null ) {
		$settings = is_array( $settings ) ? $settings : $this->settings->all();
		$base_url = defined( 'SUBIN_CLOUD_API_BASE_URL' ) ? SUBIN_CLOUD_API_BASE_URL : 'https://inboxmend.com/api/v1/plugin';
		$base_url = esc_url_raw( (string) $base_url );

		if ( 'https://inboxmend.com/api/v1/plugin' === $base_url && $this->is_local_cloud_endpoint( $settings['cloud_sync_endpoint'] ?? '' ) ) {
			$base_url = esc_url_raw( (string) $settings['cloud_sync_endpoint'] );
		}

		/**
		 * Filters the Cloud plugin API base URL for local development.
		 *
		 * @param string $base_url Cloud plugin API base URL.
		 */
		return untrailingslashit( apply_filters( 'subin_cloud_api_base_url', $base_url ) );
	}

	/**
	 * Determine whether a stored endpoint is a local development Cloud endpoint.
	 *
	 * @param mixed $endpoint Endpoint.
	 * @return bool
	 */
	private function is_local_cloud_endpoint( $endpoint ) {
		$host = wp_parse_url( (string) $endpoint, PHP_URL_HOST );
		$host = strtolower( (string) $host );

		return in_array( $host, array( 'localhost', '127.0.0.1', '::1', 'host.docker.internal' ), true );
	}

	/**
	 * Persist only changed Cloud settings and report storage failures.
	 *
	 * The diagnostic action receives only setting names, never token values.
	 *
	 * @param array<string,mixed> $changes Changed Cloud settings.
	 * @param string              $context Write context.
	 * @return true|\WP_Error
	 */
	private function patch_cloud_settings( array $changes, $context ) {
		$result = $this->settings->patch( $changes );
		if ( ! is_wp_error( $result ) ) {
			return true;
		}

		/**
		 * Fires when Cloud settings could not be atomically persisted.
		 *
		 * Handlers must not call CloudSyncService::save_error(), because that method
		 * uses the same atomic settings path.
		 *
		 * @param \WP_Error        $result Settings storage error.
		 * @param string           $context Sanitized write context.
		 * @param array<int,string> $keys Changed setting names.
		 */
		do_action(
			'subin_cloud_settings_patch_failed',
			$result,
			sanitize_key( (string) $context ),
			array_values( array_map( 'sanitize_key', array_keys( $changes ) ) )
		);

		return $result;
	}

	/**
	 * Save connection error.
	 *
	 * @param string $message Message.
	 * @param string $status Connection status.
	 * @return void
	 */
	private function save_error( $message, $status = 'error' ) {
		$status = sanitize_key( (string) $status );
		$result = $this->patch_cloud_settings(
			array(
				'cloud_sync_last_sync_error'   => sanitize_textarea_field( (string) $message ),
				'cloud_sync_connection_status' => $status,
			),
			'save_error'
		);
		if ( ! is_wp_error( $result ) ) {
			$this->identity->update_connection_status( $status );
		}
	}

	/**
	 * Sanitize sync mode.
	 *
	 * @param mixed $mode Mode.
	 * @return string
	 */
	private function sanitize_sync_mode( $mode ) {
		$mode = sanitize_key( (string) $mode );

		return in_array( $mode, array( 'health_only', 'lead_summary', 'full_lead_data' ), true ) ? $mode : 'lead_summary';
	}

	/**
	 * Sanitize dot-separated event type.
	 *
	 * @param string $event_type Event type.
	 * @return string
	 */
	private function sanitize_event_type( $event_type ) {
		return preg_replace( '/[^a-z0-9_.-]/', '', strtolower( (string) $event_type ) );
	}

	/**
	 * Format datetime as RFC3339.
	 *
	 * @param mixed $date Date.
	 * @return string|null
	 */
	private function rfc3339( $date ) {
		$timestamp = strtotime( (string) $date );

		return $timestamp ? gmdate( 'c', $timestamp ) : null;
	}

	/**
	 * Resolve a Cloud hash without changing legacy queued payload contracts.
	 *
	 * Raw values available at snapshot/full-payload time use a site-keyed HMAC.
	 * Older summary queue rows contain only an existing SHA-256 value, which is
	 * preserved because the original input cannot be recovered safely.
	 *
	 * @param mixed $raw Raw value when locally available.
	 * @param mixed $existing Existing queue hash.
	 * @return string
	 */
	private function resolved_hash( $raw, $existing = '' ) {
		$raw = trim( (string) $raw );
		if ( '' !== $raw ) {
			return $this->hash_value( $raw );
		}

		return sanitize_text_field( (string) $existing );
	}

	/**
	 * Hash a scalar value using a stable site-keyed HMAC.
	 *
	 * @param mixed $value Value.
	 * @return string
	 */
	private function hash_value( $value ) {
		$value = strtolower( trim( (string) $value ) );

		return '' === $value ? '' : hash_hmac( 'sha256', $value, $this->hash_key() );
	}

	/**
	 * Build a stable per-site HMAC key that survives Cloud token rotation.
	 *
	 * @return string Binary key material.
	 */
	private function hash_key() {
		if ( '' !== $this->hash_key ) {
			return $this->hash_key;
		}

		$identity = $this->identity->get();
		$salt     = function_exists( 'wp_salt' ) ? wp_salt( 'auth' ) : ( defined( 'AUTH_SALT' ) ? AUTH_SALT : site_url( '/' ) );
		$material = (string) ( $identity['site_uuid'] ?? '' ) . '|' . (string) $salt;
		$material = (string) apply_filters( 'subin_cloud_hash_key_material', $material, $identity );
		if ( '' === $material ) {
			$material = (string) ( $identity['site_uuid'] ?? '' ) . '|' . (string) $salt;
		}

		$this->hash_key = hash( 'sha256', $material, true );

		return $this->hash_key;
	}

	/**
	 * Mask a customer name for lead_summary mode.
	 *
	 * @param mixed $name Name.
	 * @return string
	 */
	private function name_preview( $name ) {
		$name = trim( sanitize_text_field( (string) $name ) );
		if ( '' === $name ) {
			return '';
		}

		$length = function_exists( 'mb_strlen' ) ? mb_strlen( $name ) : strlen( $name );
		if ( $length <= 1 ) {
			return '***';
		}

		$initial = function_exists( 'mb_substr' ) ? mb_substr( $name, 0, 1 ) : substr( $name, 0, 1 );

		return $initial . '***';
	}

	/**
	 * Mask an email for summary mode.
	 *
	 * @param mixed $email Email.
	 * @return string
	 */
	private function email_preview( $email ) {
		$email = sanitize_email( (string) $email );
		if ( '' === $email || false === strpos( $email, '@' ) ) {
			return '';
		}

		list( $local, $domain ) = explode( '@', $email, 2 );

		return substr( $local, 0, 2 ) . '***@' . $domain;
	}

	/**
	 * Full fields payload.
	 *
	 * @param mixed $fields Fields.
	 * @return array<int,array<string,mixed>>
	 */
	private function full_fields( $fields ) {
		if ( ! is_array( $fields ) ) {
			return array();
		}

		return array_map(
			function ( $field ) {
				$field = is_array( $field ) ? $field : array();
				$is_sensitive = ! empty( $field['is_sensitive'] );
				return array(
					'field_key'    => sanitize_text_field( (string) ( $field['key'] ?? $field['field_key'] ?? '' ) ),
					'field_label'  => sanitize_text_field( (string) ( $field['label'] ?? $field['field_label'] ?? '' ) ),
					'value'        => $is_sensitive ? '[redacted]' : sanitize_textarea_field( (string) ( $field['value'] ?? '' ) ),
					'is_sensitive' => $is_sensitive,
				);
			},
			$fields
		);
	}

	/**
	 * Build one allowlisted incremental note payload for the effective Cloud mode.
	 *
	 * Note bodies are never hashed or retained in reduced modes. A queued full-data
	 * event therefore remains safe if consent is downgraded before it is delivered.
	 *
	 * @param array<string,mixed> $note Stored immutable note mutation.
	 * @param string              $mode Effective sync mode.
	 * @return array<string,mixed>
	 */
	private function note_event_payload( array $note, $mode ) {
		$deleted = ! empty( $note['deleted'] );
		$payload = array(
			'external_note_id' => (string) absint( $note['id'] ?? 0 ),
			'version'          => max( 1, absint( $note['note_version'] ?? 1 ) ),
			'deleted'          => $deleted,
			'created_at'       => $this->rfc3339( $note['created_at'] ?? '' ),
			'updated_at'       => $this->rfc3339( $note['updated_at'] ?? '' ),
		);
		if ( 'full_lead_data' === $mode && ! $deleted && array_key_exists( 'body', $note ) ) {
			$payload['body'] = sanitize_textarea_field( (string) ( $note['body'] ?? '' ) );
		}

		return $payload;
	}

	/**
	 * Full notes payload.
	 *
	 * @param mixed $notes Notes.
	 * @return array<int,array<string,mixed>>
	 */
	private function full_notes( $notes ) {
		if ( ! is_array( $notes ) ) {
			return array();
		}

		return array_map(
			function ( $note ) {
				$note = is_array( $note ) ? $note : array();
				return array(
					'external_note_id' => (string) ( $note['id'] ?? '' ),
					'author_name'      => '',
					'body'             => sanitize_textarea_field( (string) ( $note['note'] ?? '' ) ),
					'version'          => max( 1, absint( $note['note_version'] ?? 1 ) ),
					'created_at'       => $this->rfc3339( $note['created_at'] ?? '' ),
					'updated_at'       => $this->rfc3339( $note['updated_at'] ?? $note['created_at'] ?? '' ),
				);
			},
			$notes
		);
	}
}
