<?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;

	/**
	 * 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' ) );
		self::schedule();
	}

	/**
	 * 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,
					),
				),
			)
		);
	}

	/**
	 * 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 )
			);
		}

		$mode   = $this->polling_sync_mode( $settings, $request->get_param( 'sync_mode' ) );
		$limit  = max( 1, min( 100, absint( $request->get_param( 'limit' ) ) ) );
		$events = $this->events->get_pending_batch( $limit );
		$payload = $this->sync_payload( $events );
		$ids     = array_map( 'absint', wp_list_pluck( $events, 'id' ) );

		$payload['sync_mode'] = $mode;
		$payload['events'] = array_map(
			function ( $event ) use ( $mode ) {
				return $this->map_event_for_cloud( $event, $mode );
			},
			$events
		);
		$payload['events'] = array_merge(
			$payload['events'],
			$this->email_log_snapshot_events_for_cloud( $mode, max( 0, $limit - count( $payload['events'] ) ) )
		);
		$payload['events'] = array_merge(
			$payload['events'],
			$this->lead_snapshot_events_for_cloud( $mode, max( 0, $limit - count( $payload['events'] ) ) )
		);

		if ( ! empty( $ids ) ) {
			$this->events->mark_sent( $ids );
		}

		$heartbeat = $this->heartbeat_payload();
		$settings['cloud_sync_connection_status'] = 'connected';
		$settings['cloud_sync_last_heartbeat_at'] = current_time( 'mysql' );
		$settings['cloud_sync_last_sync_at']      = current_time( 'mysql' );
		$settings['cloud_sync_last_sync_error']   = '';
		update_option( \SUBIN_SettingsRepository::OPTION_KEY, $settings, false );
		$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'],
					'sync_mode'    => $mode,
					'queue_count'  => $this->events->count_by_status( SyncEventRepository::STATUS_PENDING ),
					'health'       => $heartbeat['email_health'],
					'events'       => $payload['events'],
				),
			)
		);
	}

	/**
	 * 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['cloud_sync_endpoint']           = $this->api_base_url( $settings );
		$settings['cloud_sync_site_key']           = '';
		$settings['cloud_sync_license_key']        = '';
		$settings['cloud_sync_data_mode']          = $requested_mode;
		$settings['cloud_sync_full_data_consent']  = (bool) $full_data_opt_in;
		$settings['cloud_sync_enabled']            = true;
		$settings['cloud_sync_transport']          = 'polling';
		$settings['cloud_sync_polling_enabled']    = ! empty( $response['cloud_polling_enabled'] );
		$settings['cloud_sync_site_token']         = sanitize_text_field( (string) ( $response['site_token'] ?? '' ) );
		$settings['cloud_sync_allowed_mode']       = $this->sanitize_sync_mode( $response['current_sync_mode'] ?? $response['allowed_sync_mode'] ?? $requested_mode );
		$settings['cloud_sync_full_data_enabled']  = ! empty( $response['full_lead_data_enabled'] );
		$settings['cloud_sync_connection_status']  = 'connected';
		$settings['cloud_sync_site_id']            = absint( $response['site_id'] ?? 0 );
		$settings['cloud_sync_license_status']     = sanitize_key( (string) ( $response['subscription_status'] ?? $response['license_status'] ?? '' ) );
		$settings['cloud_sync_license_site_limit'] = absint( $response['subscription_site_limit'] ?? $response['license_site_limit'] ?? 0 );
		$settings['cloud_sync_heartbeat_interval'] = max( 300, absint( $response['heartbeat_interval_seconds'] ?? 300 ) );
		$settings['cloud_sync_last_sync_error']    = '';

		update_option( \SUBIN_SettingsRepository::OPTION_KEY, $settings, false );
		$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;
		}

		$settings = $this->settings->all();
		$settings['cloud_sync_allowed_mode']      = $this->sanitize_sync_mode( $response['current_sync_mode'] ?? $settings['cloud_sync_allowed_mode'] );
		$settings['cloud_sync_full_data_enabled'] = ! empty( $response['full_lead_data_enabled'] );
		$settings['cloud_sync_heartbeat_interval'] = max( 300, absint( $response['heartbeat_interval_seconds'] ?? $settings['cloud_sync_heartbeat_interval'] ) );
		$settings['cloud_sync_connection_status'] = 'connected';
		$settings['cloud_sync_last_heartbeat_at'] = current_time( 'mysql' );
		$settings['cloud_sync_last_sync_error']   = '';
		update_option( \SUBIN_SettingsRepository::OPTION_KEY, $settings, false );
		$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;
		}

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

		$settings['cloud_sync_allowed_mode']       = $this->sanitize_sync_mode( $response['current_sync_mode'] ?? $response['allowed_sync_mode'] ?? $settings['cloud_sync_allowed_mode'] );
		$settings['cloud_sync_full_data_enabled']  = ! empty( $response['full_lead_data_enabled'] );
		$settings['cloud_sync_heartbeat_interval'] = max( 300, absint( $response['heartbeat_interval_seconds'] ?? $settings['cloud_sync_heartbeat_interval'] ) );
		$settings['cloud_sync_connection_status']  = 'connected';
		$settings['cloud_sync_last_heartbeat_at']  = current_time( 'mysql' );
		$settings['cloud_sync_last_sync_error']    = '';
		update_option( \SUBIN_SettingsRepository::OPTION_KEY, $settings, false );
		$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;
		}

		$events = $this->events->get_pending_batch( $limit );
		if ( empty( $events ) ) {
			return $this->empty_sync_summary();
		}

		$payload  = $this->sync_payload( $events );
		$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->mark_failed( $ids, $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 );
		$summary       = $this->sync_response_summary( $response, count( $events ), $local_results );
		$warning       = $this->sync_warning_message( $summary );

		$settings['cloud_sync_last_sync_at']    = current_time( 'mysql' );
		$settings['cloud_sync_last_sync_error'] = $warning;
		$settings['cloud_sync_connection_status'] = 'connected';
		update_option( \SUBIN_SettingsRepository::OPTION_KEY, $settings, false );
		$this->identity->update_connection_status( 'connected' );

		return $summary;
	}

	/**
	 * 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 = $this->settings->all();
		$settings['cloud_sync_site_token']      = sanitize_text_field( (string) ( $response['site_token'] ?? '' ) );
		$settings['cloud_sync_last_sync_error'] = '';
		update_option( \SUBIN_SettingsRepository::OPTION_KEY, $settings, false );

		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['cloud_sync_enabled']           = false;
		$settings['cloud_sync_site_token']        = '';
		$settings['cloud_sync_connection_status'] = 'disconnected';
		$settings['cloud_sync_last_sync_error']   = '';
		update_option( \SUBIN_SettingsRepository::OPTION_KEY, $settings, false );
		$this->identity->update_connection_status( 'disconnected' );

		return true;
	}

	/**
	 * Build heartbeat payload.
	 *
	 * @return array<string,mixed>
	 */
	private function heartbeat_payload() {
		$settings = $this->settings->all();
		$filters  = array( 'date_range' => '30' );
		$summary  = $this->email_health->summary_counts( $filters );
		$sources  = $this->email_health->source_summary( $filters );
		$total    = max( 1, absint( $summary['total'] ?? 0 ) );
		$failed   = absint( $summary['failed'] ?? 0 );

		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'            => $failed > 0 ? 'needs_attention' : 'healthy',
			'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( $summary['sent'] ?? 0 ),
				'failed_count'    => $failed,
				'failure_rate'    => round( $failed / $total, 4 ),
				'last_success_at' => null,
				'last_failure_at' => null,
				'health_status'   => $failed > 0 ? 'needs_attention' : 'healthy',
				'metadata'        => array(
					'queue_count'              => $this->events->count_by_status( SyncEventRepository::STATUS_PENDING ),
					'no_email_generated_count' => absint( $summary['no_email_generated'] ?? 0 ),
					'unknown_count'            => absint( $summary['unknown'] ?? 0 ),
					'sources'                  => $sources,
				),
			),
		);
	}

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

		return array(
			'site_uuid' => $identity['site_uuid'],
			'batch_id'  => '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,int>
	 */
	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 ] = $id;
			}
		}

		return $map;
	}

	/**
	 * Apply Cloud per-event results to the local sync queue.
	 *
	 * @param array<string,mixed> $response Cloud response data.
	 * @param array<string,int>   $event_id_map Plugin event id to local id 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 ) {
		$results = is_array( $response['event_results'] ?? null ) ? $response['event_results'] : array();
		if ( empty( $results ) ) {
			$this->events->mark_sent( $all_ids );
			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_id      = absint( $event_id_map[ $plugin_event_id ] );
			$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' );
				$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 );
		}

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

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

		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 ?? '' ) );

		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 ( 0 === strpos( $event_type, 'email.' ) ) {
			return $this->email_payload( $event_type, $payload, $mode );
		}

		$lead = is_array( $payload['lead'] ?? null ) ? $payload['lead'] : $payload;
		if ( 'health_only' === $mode ) {
			return 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'] ?? '' ) ),
			);
		}

		$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'] ?? '' ) ),
			'title'            => sanitize_text_field( (string) ( $lead['form_name'] ?? __( 'Lead', 'submission-inbox' ) ) ),
			'email_hash'       => sanitize_text_field( (string) ( $lead['customer_email_hash'] ?? $this->hash_value( $lead['customer_email'] ?? '' ) ) ),
			'email_preview'    => sanitize_text_field( (string) ( $lead['customer_email_preview'] ?? $this->email_preview( $lead['customer_email'] ?? '' ) ) ),
			'name_preview'     => sanitize_text_field( (string) ( $lead['customer_name'] ?? '' ) ),
		);

		if ( 'full_lead_data' !== $mode ) {
			return $summary;
		}

		return 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() ),
				'notes'  => $this->full_notes( $lead['notes'] ?? array() ),
			)
		);
	}

	/**
	 * 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'    => sanitize_text_field( (string) ( $payload['to_email_hash'] ?? $this->hash_value( $payload['to_email'] ?? '' ) ) ),
			'to_email_hash'     => sanitize_text_field( (string) ( $payload['to_email_hash'] ?? $this->hash_value( $payload['to_email'] ?? '' ) ) ),
			'subject_hash'      => sanitize_text_field( (string) ( $payload['subject_hash'] ?? $this->hash_value( $payload['subject'] ?? '' ) ) ),
			'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 Email Log snapshot events for Cloud polling.
	 *
	 * Polling can run after the local sync queue has already been drained. Snapshots
	 * make Cloud Reports reflect the plugin Email Log instead of only new queue rows.
	 *
	 * @param string $mode Sync mode.
	 * @param int    $limit Maximum snapshots.
	 * @return array<int,array<string,mixed>>
	 */
	private function email_log_snapshot_events_for_cloud( $mode, $limit ) {
		if ( $limit <= 0 ) {
			return array();
		}

		global $wpdb;

		$table = $wpdb->prefix . 'subin_mail_events';
		$limit = max( 1, min( 100, absint( $limit ) ) );
		$rows  = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, lead_id, source, to_email, subject, message_hash, status, error_message, created_at, updated_at FROM {$table} ORDER BY created_at DESC, id DESC LIMIT %d",
				$limit
			),
			ARRAY_A
		); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return array_map(
			function ( $row ) use ( $mode ) {
				$row = is_array( $row ) ? $row : array();
				$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 = (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'] ?? '' ),
					'to_email_hash' => $this->hash_value( $row['to_email'] ?? '' ),
					'subject'       => sanitize_text_field( $row['subject'] ?? '' ),
					'subject_hash'  => $this->hash_value( $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'] ?? '' ),
				);

				return 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 ),
				);
			},
			is_array( $rows ) ? $rows : array()
		);
	}

	/**
	 * Build lead snapshot events for Cloud polling.
	 *
	 * Polling can run after the local event queue has already been drained. Snapshots
	 * make the Cloud Inbox usable on initial connection without requiring old queue rows.
	 *
	 * @param string $mode Sync mode.
	 * @param int    $limit Maximum snapshots.
	 * @return array<int,array<string,mixed>>
	 */
	private function lead_snapshot_events_for_cloud( $mode, $limit ) {
		if ( 'health_only' === $mode || $limit <= 0 ) {
			return array();
		}

		global $wpdb;

		$leads_table = $wpdb->prefix . 'subin_leads';
		$limit       = max( 1, min( 100, absint( $limit ) ) );
		$leads       = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$leads_table} ORDER BY submitted_at DESC, id DESC LIMIT %d",
				$limit
			),
			ARRAY_A
		); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return array_map(
			function ( $lead ) use ( $mode ) {
				$lead = is_array( $lead ) ? $lead : array();

				if ( 'full_lead_data' === $mode ) {
					$lead['fields'] = $this->lead_fields_for_cloud( absint( $lead['id'] ?? 0 ) );
					$lead['notes']  = $this->lead_notes_for_cloud( absint( $lead['id'] ?? 0 ) );
				}

				$updated_at = (string) ( $lead['updated_at'] ?? $lead['submitted_at'] ?? '' );

				return array(
					'plugin_event_id' => 'subin_lead_snapshot_' . absint( $lead['id'] ?? 0 ) . '_' . $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 ),
				);
			},
			$leads
		);
	}

	/**
	 * Lead fields for full Cloud snapshots.
	 *
	 * @param int $lead_id Lead id.
	 * @return array<int,array<string,mixed>>
	 */
	private function lead_fields_for_cloud( $lead_id ) {
		if ( $lead_id <= 0 ) {
			return array();
		}

		global $wpdb;

		$table = $wpdb->prefix . 'subin_lead_fields';
		$rows  = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT field_key, field_label, field_value, is_sensitive FROM {$table} WHERE lead_id = %d ORDER BY id ASC",
				$lead_id
			),
			ARRAY_A
		); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return array_map(
			function ( $row ) {
				$is_sensitive = ! empty( $row['is_sensitive'] );
				return array(
					'key'          => $row['field_key'] ?? '',
					'label'        => $row['field_label'] ?? '',
					'value'        => $is_sensitive ? '[redacted]' : ( $row['field_value'] ?? '' ),
					'is_sensitive' => $is_sensitive,
				);
			},
			is_array( $rows ) ? $rows : array()
		);
	}

	/**
	 * Lead notes for full Cloud snapshots.
	 *
	 * @param int $lead_id Lead id.
	 * @return array<int,array<string,mixed>>
	 */
	private function lead_notes_for_cloud( $lead_id ) {
		if ( $lead_id <= 0 ) {
			return array();
		}

		global $wpdb;

		$table = $wpdb->prefix . 'subin_lead_notes';
		$rows  = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, note, created_at FROM {$table} WHERE lead_id = %d ORDER BY created_at ASC, id ASC",
				$lead_id
			),
			ARRAY_A
		); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * 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();
		$allowed  = $this->sanitize_sync_mode( $settings['cloud_sync_allowed_mode'] ?? $settings['cloud_sync_data_mode'] ?? 'lead_summary' );
		$requested = $this->sanitize_sync_mode( $settings['cloud_sync_data_mode'] ?? 'lead_summary' );
		$mode      = '' !== $allowed ? $allowed : $requested;

		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 );
	}

	/**
	 * Save connection error.
	 *
	 * @param string $message Message.
	 * @param string $status Connection status.
	 * @return void
	 */
	private function save_error( $message, $status = 'error' ) {
		$settings = $this->settings->all();
		$settings['cloud_sync_last_sync_error']   = sanitize_textarea_field( (string) $message );
		$settings['cloud_sync_connection_status'] = sanitize_key( (string) $status );
		update_option( \SUBIN_SettingsRepository::OPTION_KEY, $settings, false );
		$this->identity->update_connection_status( $settings['cloud_sync_connection_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;
	}

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

		return '' === $value ? '' : hash( 'sha256', $value );
	}

	/**
	 * 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
		);
	}

	/**
	 * 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'] ?? '' ) ),
					'created_at'       => $this->rfc3339( $note['created_at'] ?? '' ),
				);
			},
			$notes
		);
	}
}
