<?php
/**
 * Automated Square ↔ WordPress/WooCommerce customer sync (WP-Cron).
 *
 * Runs every 15 minutes when marketing sync is enabled and Square OAuth is connected.
 * Direction is configurable: Square → WordPress, WordPress → Square, or both.
 * No merchant webhooks required — uses Square Customers Search with a 1-hour overlap window.
 *
 * @package SHC4WC
 */

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

class SHC4WC_Customer_Sync {

	/** Recurring cron hook name (as specified for this feature). */
	const CRON_HOOK = 'square_connect_quarterly_customer_sync';

	/** Custom schedule interval key. */
	const CRON_SCHEDULE = 'every_15_minutes';

	/** Merchant toggle option (yes / no). */
	const OPTION_ENABLE = 'square_enable_marketing_sync';

	/** Sync direction option: square_to_wp | wp_to_square | both. */
	const OPTION_DIRECTION = 'square_customer_sync_direction';

	/** When yes, manual Sync customers also queues background existing sync. */
	const OPTION_INCLUDE_EXISTING = 'square_customer_sync_include_existing';

	/** User meta key for Square customer ID. */
	const META_SQUARE_CUSTOMER_ID = '_square_customer_id';

	/** Prevent overlapping recent sync runs. */
	const LOCK_TRANSIENT = 'shc4wc_customer_sync_lock';

	/** Option storing background full-sync job state. */
	const FULL_JOB_OPTION = 'shc4wc_customer_full_sync_job';

	/** Single-event hook for one full-sync batch. */
	const FULL_BATCH_HOOK = 'shc4wc_customer_full_sync_batch';

	/** Customers / users processed per background batch (keeps site light). */
	const FULL_BATCH_SIZE = 25;

	/** Seconds between background batches. */
	const FULL_BATCH_DELAY = 30;

	/** @var self|null */
	private static $instance = null;

	/**
	 * Singleton bootstrap.
	 *
	 * @return self
	 */
	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
			self::maybe_schedule_cron();
		}
		return self::$instance;
	}

	/**
	 * Wire hooks.
	 */
	private function __construct() {
		add_filter( 'cron_schedules', array( __CLASS__, 'register_cron_schedules' ) );
		add_action( self::CRON_HOOK, array( $this, 'sync_recent_customers' ) );
		add_action( self::FULL_BATCH_HOOK, array( $this, 'process_full_sync_batch' ) );
		add_action( 'admin_post_shc4wc_customer_sync_now', array( $this, 'handle_manual_sync' ) );
		add_action( 'admin_post_shc4wc_customer_sync_cancel_full', array( $this, 'handle_cancel_full_sync' ) );
		add_action( 'admin_notices', array( $this, 'maybe_render_manual_sync_notice' ) );
		add_action( 'admin_notices', array( $this, 'maybe_render_full_sync_progress' ) );
	}

	/**
	 * Register a custom 15-minute cron interval.
	 *
	 * @param array<string, array{interval:int, display:string}> $schedules Existing schedules.
	 * @return array<string, array{interval:int, display:string}>
	 */
	public static function register_cron_schedules( $schedules ) {
		if ( ! is_array( $schedules ) ) {
			$schedules = array();
		}

		$schedules[ self::CRON_SCHEDULE ] = array(
			'interval' => 15 * MINUTE_IN_SECONDS, // 900 seconds.
			'display'  => __( 'Every 15 Minutes', 'payments-connect-square' ),
		);

		return $schedules;
	}

	/**
	 * Schedule the recurring customer sync if missing.
	 */
	public static function maybe_schedule_cron() {
		// Ensure custom interval is registered before scheduling.
		add_filter( 'cron_schedules', array( __CLASS__, 'register_cron_schedules' ) );

		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_event( time() + MINUTE_IN_SECONDS, self::CRON_SCHEDULE, self::CRON_HOOK );
		}
	}

	/**
	 * Clear scheduled sync events (plugin deactivation).
	 */
	public static function unschedule_cron() {
		$timestamp = wp_next_scheduled( self::CRON_HOOK );
		while ( $timestamp ) {
			wp_unschedule_event( $timestamp, self::CRON_HOOK );
			$timestamp = wp_next_scheduled( self::CRON_HOOK );
		}
		wp_clear_scheduled_hook( self::CRON_HOOK );
		wp_clear_scheduled_hook( self::FULL_BATCH_HOOK );
		delete_option( self::FULL_JOB_OPTION );
	}

	/**
	 * Whether marketing / customer sync is enabled.
	 *
	 * @return bool
	 */
	public static function is_enabled() {
		return 'yes' === get_option( self::OPTION_ENABLE, 'no' );
	}

	/**
	 * Selected sync direction.
	 *
	 * @return string square_to_wp|wp_to_square|both
	 */
	public static function get_direction() {
		$direction = get_option( self::OPTION_DIRECTION, 'square_to_wp' );
		$allowed   = array( 'square_to_wp', 'wp_to_square', 'both' );
		if ( ! in_array( $direction, $allowed, true ) ) {
			return 'square_to_wp';
		}
		return $direction;
	}

	/**
	 * Whether manual Sync customers should also queue existing (full) sync.
	 *
	 * @return bool
	 */
	public static function include_existing_on_manual() {
		return 'yes' === get_option( self::OPTION_INCLUDE_EXISTING, 'no' );
	}

	/**
	 * Cron callback: sync recent customers (1-hour lookback) per selected direction.
	 */
	public function sync_recent_customers() {
		$this->run_sync( false );
	}

	/**
	 * Manual sync form submit: always sync recent; optionally queue background full sync.
	 */
	public function handle_manual_sync() {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to sync Square customers.', 'payments-connect-square' ) );
		}

		check_admin_referer( 'shc4wc_customer_sync_now' );

		$include_existing = self::include_existing_on_manual() && ! self::is_full_sync_running();

		$result = $this->run_sync( true, false );

		if ( $include_existing ) {
			$queued = $this->queue_full_sync_job();
			if ( is_wp_error( $queued ) ) {
				$result['status']  = 'error' === $result['status'] ? 'error' : 'success';
				$result['message'] = ( isset( $result['message'] ) ? $result['message'] . ' ' : '' ) . $queued->get_error_message();
			} else {
				$result['message'] = ( isset( $result['message'] ) ? $result['message'] . ' ' : '' )
					. __( 'Existing customers are syncing slowly in the background (small batches) so the site stays light. You can leave this page.', 'payments-connect-square' );
				$result['full_queued'] = true;
			}
		}

		set_transient(
			'shc4wc_customer_sync_notice_' . get_current_user_id(),
			$result,
			5 * MINUTE_IN_SECONDS
		);

		$redirect = wp_get_referer();
		if ( ! is_string( $redirect ) || '' === $redirect ) {
			$redirect = SHC4WC_Connection_Settings::page_url();
		}

		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Cancel a running background full sync.
	 */
	public function handle_cancel_full_sync() {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to cancel customer sync.', 'payments-connect-square' ) );
		}

		check_admin_referer( 'shc4wc_customer_sync_cancel_full' );
		$this->clear_full_sync_job();

		set_transient(
			'shc4wc_customer_sync_notice_' . get_current_user_id(),
			array(
				'status'  => 'success',
				'message' => __( 'Background existing-customer sync was cancelled.', 'payments-connect-square' ),
			),
			5 * MINUTE_IN_SECONDS
		);

		$redirect = wp_get_referer();
		if ( ! is_string( $redirect ) || '' === $redirect ) {
			$redirect = SHC4WC_Connection_Settings::page_url();
		}

		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Whether a background full sync job is active.
	 *
	 * @return bool
	 */
	public static function is_full_sync_running() {
		$job = get_option( self::FULL_JOB_OPTION, array() );
		return is_array( $job ) && ! empty( $job['status'] ) && 'running' === $job['status'];
	}

	/**
	 * Background full-sync job state (or empty array).
	 *
	 * @return array<string,mixed>
	 */
	public static function get_full_sync_job() {
		$job = get_option( self::FULL_JOB_OPTION, array() );
		return is_array( $job ) ? $job : array();
	}

	/**
	 * Admin notice after a manual sync.
	 */
	public function maybe_render_manual_sync_notice() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$page = (string) filter_input( INPUT_GET, 'page', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
		if ( SHC4WC_Connection_Settings::page_slug() !== $page ) {
			return;
		}

		$key  = 'shc4wc_customer_sync_notice_' . get_current_user_id();
		$data = get_transient( $key );
		if ( ! is_array( $data ) || empty( $data['status'] ) ) {
			return;
		}
		delete_transient( $key );

		$status  = (string) $data['status'];
		$message = isset( $data['message'] ) ? (string) $data['message'] : '';
		$class   = ( 'success' === $status ) ? 'notice-success' : 'notice-error';

		echo '<div class="notice ' . esc_attr( $class ) . ' is-dismissible"><p>' . esc_html( $message ) . '</p>';

		$created_users = isset( $data['created_users'] ) && is_array( $data['created_users'] ) ? $data['created_users'] : array();
		if ( ! empty( $created_users ) ) {
			$links    = array();
			$shown    = 0;
			$max_show = 15;
			foreach ( $created_users as $row ) {
				if ( ! is_array( $row ) ) {
					continue;
				}
				$email   = isset( $row['email'] ) ? sanitize_email( (string) $row['email'] ) : '';
				$user_id = isset( $row['user_id'] ) ? absint( $row['user_id'] ) : 0;
				if ( '' === $email || $user_id < 1 ) {
					continue;
				}
				if ( $shown >= $max_show ) {
					break;
				}
				$edit_url = get_edit_user_link( $user_id );
				if ( ! is_string( $edit_url ) || '' === $edit_url ) {
					$links[] = '<code>' . esc_html( $email ) . '</code>';
				} else {
					$links[] = '<a href="' . esc_url( $edit_url ) . '"><code>' . esc_html( $email ) . '</code></a>';
				}
				++$shown;
			}
			if ( ! empty( $links ) ) {
				$extra  = count( $created_users ) - $shown;
				$suffix = ( $extra > 0 )
					? ' ' . sprintf(
						/* translators: %d: additional created count */
						esc_html__( 'and %d more…', 'payments-connect-square' ),
						$extra
					)
					: '';
				echo '<p>' . esc_html__( 'Created in WordPress:', 'payments-connect-square' ) . ' ' . implode( ', ', $links ) . $suffix . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			}
		}

		echo '</div>';
	}

	/**
	 * Progress notice while background existing sync is running.
	 */
	public function maybe_render_full_sync_progress() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$page = (string) filter_input( INPUT_GET, 'page', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
		if ( SHC4WC_Connection_Settings::page_slug() !== $page ) {
			return;
		}

		$job = self::get_full_sync_job();
		if ( empty( $job['status'] ) ) {
			return;
		}

		$status = (string) $job['status'];
		if ( 'completed' === $status || 'error' === $status ) {
			$created = isset( $job['created'] ) ? (int) $job['created'] : 0;
			$updated = isset( $job['updated'] ) ? (int) $job['updated'] : 0;
			$skipped = isset( $job['skipped'] ) ? (int) $job['skipped'] : 0;
			$class   = ( 'completed' === $status ) ? 'notice-success' : 'notice-error';

			echo '<div class="notice ' . esc_attr( $class ) . ' is-dismissible"><p>';
			if ( 'completed' === $status ) {
				echo esc_html(
					sprintf(
						/* translators: 1: created, 2: updated, 3: skipped */
						__( 'Background existing-customer sync finished. Created: %1$d, updated: %2$d, skipped: %3$d.', 'payments-connect-square' ),
						$created,
						$updated,
						$skipped
					)
				);
			} else {
				$err = isset( $job['error'] ) ? (string) $job['error'] : '';
				echo esc_html(
					sprintf(
						/* translators: %s: error message */
						__( 'Background existing-customer sync failed: %s', 'payments-connect-square' ),
						$err
					)
				);
			}
			echo '</p></div>';
			delete_option( self::FULL_JOB_OPTION );
			return;
		}

		if ( 'running' !== $status ) {
			return;
		}

		$created = isset( $job['created'] ) ? (int) $job['created'] : 0;
		$updated = isset( $job['updated'] ) ? (int) $job['updated'] : 0;
		$skipped = isset( $job['skipped'] ) ? (int) $job['skipped'] : 0;
		$phase   = isset( $job['phase'] ) ? (string) $job['phase'] : '';
		$cancel  = self::get_cancel_full_sync_url();

		$phase_label = ( 'wp_to_square' === $phase )
			? __( 'WordPress → Square', 'payments-connect-square' )
			: __( 'Square → WordPress', 'payments-connect-square' );

		echo '<div class="notice notice-info"><p>';
		echo esc_html(
			sprintf(
				/* translators: 1: phase label, 2: created, 3: updated, 4: skipped */
				__( 'Existing customer sync is running in the background (%1$s). Progress so far — created: %2$d, updated: %3$d, skipped: %4$d. Batches run every ~30 seconds so the site stays light.', 'payments-connect-square' ),
				$phase_label,
				$created,
				$updated,
				$skipped
			)
		);
		echo ' <a href="' . esc_url( $cancel ) . '">' . esc_html__( 'Cancel', 'payments-connect-square' ) . '</a>';
		echo '</p></div>';
	}

	/**
	 * Nonced URL used by the manual sync form action.
	 *
	 * @return string
	 */
	public static function get_manual_sync_url() {
		return admin_url( 'admin-post.php?action=shc4wc_customer_sync_now' );
	}

	/**
	 * Nonced cancel URL for background full sync.
	 *
	 * @return string
	 */
	public static function get_cancel_full_sync_url() {
		return wp_nonce_url(
			admin_url( 'admin-post.php?action=shc4wc_customer_sync_cancel_full' ),
			'shc4wc_customer_sync_cancel_full'
		);
	}

	/**
	 * Queue a background full-directory sync job (batched).
	 *
	 * @return true|WP_Error
	 */
	private function queue_full_sync_job() {
		if ( self::is_full_sync_running() ) {
			return new WP_Error(
				'shc4wc_full_sync_running',
				__( 'An existing-customer sync is already running in the background.', 'payments-connect-square' )
			);
		}

		$direction = self::get_direction();
		$phases    = array();
		if ( in_array( $direction, array( 'square_to_wp', 'both' ), true ) ) {
			$phases[] = 'square_to_wp';
		}
		if ( in_array( $direction, array( 'wp_to_square', 'both' ), true ) ) {
			$phases[] = 'wp_to_square';
		}
		if ( empty( $phases ) ) {
			return new WP_Error( 'shc4wc_no_phases', __( 'No sync direction selected.', 'payments-connect-square' ) );
		}

		$job = array(
			'status'     => 'running',
			'direction'  => $direction,
			'phases'     => $phases,
			'phase'      => $phases[0],
			'cursor'     => '',
			'wp_offset'  => 0,
			'created'    => 0,
			'updated'    => 0,
			'skipped'    => 0,
			'started_at' => gmdate( 'c' ),
			'updated_at' => gmdate( 'c' ),
			'error'      => '',
		);

		update_option( self::FULL_JOB_OPTION, $job, false );
		wp_clear_scheduled_hook( self::FULL_BATCH_HOOK );
		wp_schedule_single_event( time() + 5, self::FULL_BATCH_HOOK );
		spawn_cron();

		$this->log( 'Queued background full customer sync', array( 'direction' => $direction ) );

		return true;
	}

	/**
	 * Clear full sync job + scheduled batches.
	 */
	private function clear_full_sync_job() {
		wp_clear_scheduled_hook( self::FULL_BATCH_HOOK );
		delete_option( self::FULL_JOB_OPTION );
	}

	/**
	 * Process one small batch of the background full sync, then reschedule.
	 */
	public function process_full_sync_batch() {
		$job = self::get_full_sync_job();
		if ( empty( $job['status'] ) || 'running' !== $job['status'] ) {
			return;
		}

		if ( ! self::is_enabled() || ! SHC4WC_Plugin::has_oauth_connection() ) {
			$job['status']     = 'error';
			$job['error']      = __( 'Customer sync disabled or Square disconnected during full sync.', 'payments-connect-square' );
			$job['updated_at'] = gmdate( 'c' );
			update_option( self::FULL_JOB_OPTION, $job, false );
			return;
		}

		$token = SHC4WC_OAuth_Token::get_access_token();
		if ( '' === (string) $token ) {
			$job['status']     = 'error';
			$job['error']      = __( 'Square access token missing during full sync.', 'payments-connect-square' );
			$job['updated_at'] = gmdate( 'c' );
			update_option( self::FULL_JOB_OPTION, $job, false );
			return;
		}

		// Soft lock: skip this tick if a recent sync is mid-flight.
		if ( get_transient( self::LOCK_TRANSIENT ) ) {
			wp_schedule_single_event( time() + self::FULL_BATCH_DELAY, self::FULL_BATCH_HOOK );
			return;
		}

		set_transient( self::LOCK_TRANSIENT, 1, 2 * MINUTE_IN_SECONDS );

		try {
			$phase = isset( $job['phase'] ) ? (string) $job['phase'] : 'square_to_wp';

			if ( 'square_to_wp' === $phase ) {
				$batch = $this->run_full_square_to_wp_batch( $job );
			} else {
				$batch = $this->run_full_wp_to_square_batch( $job );
			}

			$job['created'] += (int) $batch['created'];
			$job['updated'] += (int) $batch['updated'];
			$job['skipped'] += (int) $batch['skipped'];
			$job['updated_at'] = gmdate( 'c' );

			if ( ! empty( $batch['error'] ) ) {
				$job['status'] = 'error';
				$job['error']  = (string) $batch['error'];
				update_option( self::FULL_JOB_OPTION, $job, false );
				$this->log( 'Background full sync failed', array( 'error' => $job['error'] ), 'error' );
				return;
			}

			if ( ! empty( $batch['done_phase'] ) ) {
				$phases = isset( $job['phases'] ) && is_array( $job['phases'] ) ? $job['phases'] : array();
				$idx    = array_search( $phase, $phases, true );
				$next   = ( false !== $idx && isset( $phases[ $idx + 1 ] ) ) ? $phases[ $idx + 1 ] : null;

				if ( null === $next ) {
					$job['status'] = 'completed';
					$job['phase']  = '';
					$job['cursor'] = '';
					update_option( self::FULL_JOB_OPTION, $job, false );
					$this->log(
						'Background full sync completed',
						array(
							'created' => $job['created'],
							'updated' => $job['updated'],
							'skipped' => $job['skipped'],
						)
					);
					// Keep completed state briefly for UI, then clear via next load after 10 min.
					return;
				}

				$job['phase']     = $next;
				$job['cursor']    = '';
				$job['wp_offset'] = 0;
			} else {
				if ( isset( $batch['cursor'] ) ) {
					$job['cursor'] = (string) $batch['cursor'];
				}
				if ( isset( $batch['wp_offset'] ) ) {
					$job['wp_offset'] = (int) $batch['wp_offset'];
				}
			}

			update_option( self::FULL_JOB_OPTION, $job, false );

			if ( 'running' === $job['status'] ) {
				wp_schedule_single_event( time() + self::FULL_BATCH_DELAY, self::FULL_BATCH_HOOK );
			}
		} finally {
			delete_transient( self::LOCK_TRANSIENT );
		}
	}

	/**
	 * One Square→WP batch for background full sync.
	 *
	 * @param array<string,mixed> $job Job state.
	 * @return array<string,mixed>
	 */
	private function run_full_square_to_wp_batch( array $job ) {
		$cursor   = isset( $job['cursor'] ) ? (string) $job['cursor'] : '';
		$response = $this->search_square_customers( null, '' !== $cursor ? $cursor : null, self::FULL_BATCH_SIZE );

		if ( is_wp_error( $response ) ) {
			return array(
				'created' => 0,
				'updated' => 0,
				'skipped' => 0,
				'error'   => $response->get_error_message(),
			);
		}

		$customers = isset( $response['customers'] ) && is_array( $response['customers'] ) ? $response['customers'] : array();
		$created   = 0;
		$updated   = 0;
		$skipped   = 0;

		foreach ( $customers as $customer ) {
			if ( ! is_array( $customer ) ) {
				continue;
			}

			$result = $this->upsert_wordpress_customer( $customer );
			$status = isset( $result['status'] ) ? (string) $result['status'] : 'skipped';
			if ( 'created' === $status ) {
				++$created;
			} elseif ( 'updated' === $status ) {
				++$updated;
			} else {
				++$skipped;
			}
		}

		$next_cursor = isset( $response['cursor'] ) ? (string) $response['cursor'] : '';
		$done        = ( '' === $next_cursor );

		return array(
			'created'    => $created,
			'updated'    => $updated,
			'skipped'    => $skipped,
			'cursor'     => $next_cursor,
			'done_phase' => $done,
		);
	}

	/**
	 * One WP→Square batch for background full sync.
	 *
	 * @param array<string,mixed> $job Job state.
	 * @return array<string,mixed>
	 */
	private function run_full_wp_to_square_batch( array $job ) {
		$offset   = isset( $job['wp_offset'] ) ? max( 0, (int) $job['wp_offset'] ) : 0;
		$settings = SHC4WC_Plugin::get_settings();
		$api      = new SHC4WC_API( $settings );
		$role     = $this->get_customer_role();

		$users = get_users(
			array(
				'role'    => $role,
				'number'  => self::FULL_BATCH_SIZE,
				'offset'  => $offset,
				'orderby' => 'ID',
				'order'   => 'ASC',
			)
		);

		if ( ! is_array( $users ) || empty( $users ) ) {
			return array(
				'created'    => 0,
				'updated'    => 0,
				'skipped'    => 0,
				'wp_offset'  => $offset,
				'done_phase' => true,
			);
		}

		$created = 0;
		$updated = 0;
		$skipped = 0;

		foreach ( $users as $user ) {
			if ( ! ( $user instanceof WP_User ) ) {
				continue;
			}

			$result = $this->upsert_square_customer( $api, $user );
			$status = isset( $result['status'] ) ? (string) $result['status'] : 'skipped';
			if ( 'created' === $status ) {
				++$created;
			} elseif ( 'updated' === $status ) {
				++$updated;
			} elseif ( 'error' === $status ) {
				return array(
					'created' => $created,
					'updated' => $updated,
					'skipped' => $skipped,
					'error'   => isset( $result['error'] ) ? (string) $result['error'] : __( 'Square customer create/update failed.', 'payments-connect-square' ),
				);
			} else {
				++$skipped;
			}
		}

		$got  = count( $users );
		$done = $got < self::FULL_BATCH_SIZE;

		return array(
			'created'    => $created,
			'updated'    => $updated,
			'skipped'    => $skipped,
			'wp_offset'  => $offset + $got,
			'done_phase' => $done,
		);
	}

	/**
	 * Run recent customer sync (1-hour lookback). Full sync is background-only.
	 *
	 * @param bool $manual Whether this was triggered from the admin button.
	 * @param bool $full   Ignored (kept for BC); full sync uses queue_full_sync_job().
	 * @return array<string,mixed>
	 */
	public function run_sync( $manual = false, $full = false ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
		if ( ! self::is_enabled() ) {
			return array(
				'status'  => 'error',
				'message' => __( 'Enable Customer sync and save settings before running a manual sync.', 'payments-connect-square' ),
			);
		}

		if ( ! class_exists( 'SHC4WC_Plugin' ) || ! SHC4WC_Plugin::has_oauth_connection() ) {
			return array(
				'status'  => 'error',
				'message' => __( 'Connect Square before syncing customers.', 'payments-connect-square' ),
			);
		}

		$token = SHC4WC_OAuth_Token::get_access_token();
		if ( '' === (string) $token ) {
			$this->log( 'Customer sync skipped: missing access token.', array(), 'warning' );
			return array(
				'status'  => 'error',
				'message' => __( 'Square access token is missing. Reconnect Square and try again.', 'payments-connect-square' ),
			);
		}

		if ( get_transient( self::LOCK_TRANSIENT ) ) {
			$this->log( 'Customer sync skipped: previous run still in progress.', array(), 'info' );
			return array(
				'status'  => 'error',
				'message' => __( 'A customer sync is already running. Please wait a moment and try again.', 'payments-connect-square' ),
			);
		}

		set_transient( self::LOCK_TRANSIENT, 1, 12 * MINUTE_IN_SECONDS );

		try {
			$created_after = gmdate( 'Y-m-d\TH:i:s\Z', strtotime( '-1 hour' ) );
			$direction     = self::get_direction();
			$totals        = array(
				'created'       => 0,
				'updated'       => 0,
				'skipped'       => 0,
				'created_users' => array(),
			);
			$api_error     = '';
			$messages      = array();

			if ( in_array( $direction, array( 'square_to_wp', 'both' ), true ) ) {
				$from_square = $this->sync_square_to_wordpress( $created_after, false );
				if ( ! empty( $from_square['api_error'] ) ) {
					$api_error = (string) $from_square['api_error'];
				}
				$totals['created']      += (int) $from_square['created'];
				$totals['updated']      += (int) $from_square['updated'];
				$totals['skipped']      += (int) $from_square['skipped'];
				$totals['created_users'] = array_merge( $totals['created_users'], $from_square['created_users'] );
				$messages[]              = sprintf(
					/* translators: 1: created, 2: updated, 3: skipped */
					__( 'Square → WordPress — created: %1$d, updated: %2$d, skipped: %3$d.', 'payments-connect-square' ),
					(int) $from_square['created'],
					(int) $from_square['updated'],
					(int) $from_square['skipped']
				);
			}

			if ( '' === $api_error && in_array( $direction, array( 'wp_to_square', 'both' ), true ) ) {
				$to_square = $this->sync_wordpress_to_square( $created_after, false );
				if ( ! empty( $to_square['api_error'] ) ) {
					$api_error = (string) $to_square['api_error'];
				}
				$totals['created'] += (int) $to_square['created'];
				$totals['updated'] += (int) $to_square['updated'];
				$totals['skipped'] += (int) $to_square['skipped'];
				$messages[]         = sprintf(
					/* translators: 1: created, 2: updated, 3: skipped */
					__( 'WordPress → Square — created: %1$d, updated: %2$d, skipped: %3$d.', 'payments-connect-square' ),
					(int) $to_square['created'],
					(int) $to_square['updated'],
					(int) $to_square['skipped']
				);
			}

			$this->log(
				'Customer sync completed',
				array(
					'manual'        => (bool) $manual,
					'direction'     => $direction,
					'created_after' => $created_after,
					'created'       => $totals['created'],
					'updated'       => $totals['updated'],
					'skipped'       => $totals['skipped'],
					'api_error'     => $api_error,
				)
			);

			/**
			 * Fires after a customer sync run finishes.
			 *
			 * @param array<string,mixed> $stats Sync stats.
			 */
			do_action(
				'shc4wc_customer_sync_completed',
				array(
					'created'       => $totals['created'],
					'updated'       => $totals['updated'],
					'skipped'       => $totals['skipped'],
					'created_after' => $created_after,
					'direction'     => $direction,
					'full'          => false,
					'manual'        => (bool) $manual,
				)
			);

			if ( '' !== $api_error ) {
				return array(
					'status'        => 'error',
					'message'       => sprintf(
						/* translators: %s: API error message */
						__( 'Customer sync failed: %s', 'payments-connect-square' ),
						$api_error
					),
					'created'       => $totals['created'],
					'updated'       => $totals['updated'],
					'skipped'       => $totals['skipped'],
					'created_users' => $totals['created_users'],
				);
			}

			$summary = sprintf(
				/* translators: %s: sync mode label */
				__( 'Customer sync finished (%s).', 'payments-connect-square' ),
				__( 'recent customers — last 1 hour', 'payments-connect-square' )
			);
			if ( ! empty( $messages ) ) {
				$summary .= ' ' . implode( ' ', $messages );
			}

			return array(
				'status'        => 'success',
				'message'       => $summary,
				'created'       => $totals['created'],
				'updated'       => $totals['updated'],
				'skipped'       => $totals['skipped'],
				'created_users' => $totals['created_users'],
			);
		} finally {
			delete_transient( self::LOCK_TRANSIENT );
		}
	}

	/**
	 * Pull Square customers into WordPress.
	 *
	 * @param string|null $created_after RFC3339 UTC lower bound, or null for full sync.
	 * @param bool        $full          Full directory (higher page cap).
	 * @return array<string,mixed>
	 */
	private function sync_square_to_wordpress( $created_after, $full = false ) {
		$cursor         = null;
		$page           = 0;
		$max_pages      = $full ? 50 : 10; // Full ≈5000 customers safety cap.
		$created        = 0;
		$updated        = 0;
		$skipped        = 0;
		$created_users  = array();
		$api_error      = '';

		do {
			++$page;
			$response = $this->search_square_customers( $created_after, $cursor );

			if ( is_wp_error( $response ) ) {
				$api_error = $response->get_error_message();
				$this->log( 'Customer sync Square→WP API error', array( 'error' => $api_error ), 'error' );
				break;
			}

			$customers = isset( $response['customers'] ) && is_array( $response['customers'] )
				? $response['customers']
				: array();

			foreach ( $customers as $customer ) {
				if ( ! is_array( $customer ) ) {
					continue;
				}

				$result = $this->upsert_wordpress_customer( $customer );
				$status = isset( $result['status'] ) ? (string) $result['status'] : 'skipped';
				if ( 'created' === $status ) {
					++$created;
					if ( ! empty( $result['user_id'] ) && ! empty( $result['email'] ) && count( $created_users ) < 100 ) {
						$created_users[] = array(
							'user_id' => (int) $result['user_id'],
							'email'   => (string) $result['email'],
						);
					}
				} elseif ( 'updated' === $status ) {
					++$updated;
				} else {
					++$skipped;
				}
			}

			$cursor = isset( $response['cursor'] ) ? (string) $response['cursor'] : '';
			if ( '' === $cursor ) {
				$cursor = null;
			}
		} while ( null !== $cursor && $page < $max_pages );

		return array(
			'created'       => $created,
			'updated'       => $updated,
			'skipped'       => $skipped,
			'created_users' => $created_users,
			'api_error'     => $api_error,
		);
	}

	/**
	 * Push WordPress customers into Square.
	 *
	 * @param string|null $created_after RFC3339 UTC lower bound, or null for all WP customers.
	 * @param bool        $full          Full sync (all customer-role users).
	 * @return array<string,mixed>
	 */
	private function sync_wordpress_to_square( $created_after, $full = false ) {
		$created         = 0;
		$updated         = 0;
		$skipped         = 0;
		$created_square  = array();
		$api_error       = '';
		$settings        = SHC4WC_Plugin::get_settings();
		$api             = new SHC4WC_API( $settings );
		$users           = $this->get_wordpress_customers_for_sync( $created_after, $full );

		foreach ( $users as $user ) {
			if ( ! ( $user instanceof WP_User ) ) {
				continue;
			}

			$result = $this->upsert_square_customer( $api, $user );
			$status = isset( $result['status'] ) ? (string) $result['status'] : 'skipped';
			if ( 'created' === $status ) {
				++$created;
				if ( ! empty( $result['email'] ) && count( $created_square ) < 100 ) {
					$created_square[] = array(
						'user_id'   => (int) $user->ID,
						'email'     => (string) $result['email'],
						'square_id' => isset( $result['square_id'] ) ? (string) $result['square_id'] : '',
					);
				}
			} elseif ( 'updated' === $status ) {
				++$updated;
			} elseif ( 'error' === $status ) {
				$api_error = isset( $result['error'] ) ? (string) $result['error'] : __( 'Square customer create/update failed.', 'payments-connect-square' );
				$this->log( 'Customer sync WP→Square API error', array( 'error' => $api_error, 'user_id' => $user->ID ), 'error' );
				break;
			} else {
				++$skipped;
			}
		}

		return array(
			'created'        => $created,
			'updated'        => $updated,
			'skipped'        => $skipped,
			'created_square' => $created_square,
			'api_error'      => $api_error,
		);
	}

	/**
	 * WordPress users with customer role for sync.
	 *
	 * @param string|null $created_after RFC3339 UTC, or null/ignored when $full.
	 * @param bool        $full          All customers (paged) vs recent window.
	 * @return WP_User[]
	 */
	private function get_wordpress_customers_for_sync( $created_after, $full = false ) {
		$role = $this->get_customer_role();

		if ( $full ) {
			$all   = array();
			$page  = 1;
			$limit = 100;
			$max   = 50; // Safety: ≈5000 users.
			do {
				$batch = get_users(
					array(
						'role'    => $role,
						'number'  => $limit,
						'paged'   => $page,
						'orderby' => 'registered',
						'order'   => 'ASC',
					)
				);
				if ( ! is_array( $batch ) || empty( $batch ) ) {
					break;
				}
				$all   = array_merge( $all, $batch );
				$got   = count( $batch );
				++$page;
			} while ( $got === $limit && $page <= $max );

			return $all;
		}

		$after_ts = is_string( $created_after ) ? strtotime( $created_after ) : false;
		if ( false === $after_ts ) {
			$after_ts = time() - HOUR_IN_SECONDS;
		}

		$users = get_users(
			array(
				'role'       => $role,
				'number'     => 200,
				'orderby'    => 'registered',
				'order'      => 'ASC',
				'date_query' => array(
					array(
						'column'    => 'user_registered',
						'after'     => gmdate( 'Y-m-d H:i:s', $after_ts ),
						'inclusive' => true,
					),
				),
			)
		);

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

	/**
	 * Create or update a Square customer from a WordPress user.
	 *
	 * @param SHC4WC_API $api  API client.
	 * @param WP_User    $user WP user.
	 * @return array{status:string,email?:string,square_id?:string,error?:string}
	 */
	private function upsert_square_customer( SHC4WC_API $api, WP_User $user ) {
		$email = sanitize_email( (string) $user->user_email );
		if ( '' === $email || ! is_email( $email ) ) {
			return array( 'status' => 'skipped' );
		}

		$first = sanitize_text_field( (string) $user->first_name );
		if ( '' === $first ) {
			$first = sanitize_text_field( (string) get_user_meta( $user->ID, 'billing_first_name', true ) );
		}
		$last = sanitize_text_field( (string) $user->last_name );
		if ( '' === $last ) {
			$last = sanitize_text_field( (string) get_user_meta( $user->ID, 'billing_last_name', true ) );
		}
		$phone = sanitize_text_field( (string) get_user_meta( $user->ID, 'billing_phone', true ) );

		$existing_id = sanitize_text_field( (string) get_user_meta( $user->ID, self::META_SQUARE_CUSTOMER_ID, true ) );
		if ( '' === $existing_id ) {
			$found = $this->find_square_customer_id_by_email( $api, $email );
			if ( is_wp_error( $found ) ) {
				return array(
					'status' => 'error',
					'error'  => self::format_square_api_error( $found ),
					'email'  => $email,
				);
			}
			$existing_id = $found;
		}

		$body = array(
			'email_address' => $email,
			'reference_id'  => 'wp-' . (int) $user->ID,
		);
		if ( '' !== $first ) {
			$body['given_name'] = $first;
		}
		if ( '' !== $last ) {
			$body['family_name'] = $last;
		}
		if ( '' !== $phone ) {
			$body['phone_number'] = $phone;
		}
		if ( '' === $first && '' === $last ) {
			$body['given_name'] = $email;
		}

		if ( '' !== $existing_id ) {
			$response = $api->request( 'PUT', '/v2/customers/' . rawurlencode( $existing_id ), $body );
			if ( is_wp_error( $response ) ) {
				return array(
					'status' => 'error',
					'error'  => self::format_square_api_error( $response ),
					'email'  => $email,
				);
			}
			$square_id = isset( $response['customer']['id'] ) ? sanitize_text_field( (string) $response['customer']['id'] ) : $existing_id;
			update_user_meta( $user->ID, self::META_SQUARE_CUSTOMER_ID, $square_id );
			return array(
				'status'    => 'updated',
				'email'     => $email,
				'square_id' => $square_id,
			);
		}

		$body['idempotency_key'] = 'shc4wc-wp-customer-' . (int) $user->ID . '-' . wp_hash( $email );
		$response = $api->request( 'POST', '/v2/customers', $body );
		if ( is_wp_error( $response ) ) {
			return array(
				'status' => 'error',
				'error'  => self::format_square_api_error( $response ),
				'email'  => $email,
			);
		}

		$square_id = isset( $response['customer']['id'] ) ? sanitize_text_field( (string) $response['customer']['id'] ) : '';
		if ( '' === $square_id ) {
			return array(
				'status' => 'error',
				'error'  => __( 'Square did not return a customer ID.', 'payments-connect-square' ),
				'email'  => $email,
			);
		}

		update_user_meta( $user->ID, self::META_SQUARE_CUSTOMER_ID, $square_id );

		return array(
			'status'    => 'created',
			'email'     => $email,
			'square_id' => $square_id,
		);
	}

	/**
	 * Find an existing Square customer ID by exact email match.
	 *
	 * @param SHC4WC_API $api   API client.
	 * @param string     $email Email.
	 * @return string|WP_Error Square ID or empty string if not found.
	 */
	private function find_square_customer_id_by_email( SHC4WC_API $api, $email ) {
		$response = $api->request(
			'POST',
			'/v2/customers/search',
			array(
				'limit' => 1,
				'query' => array(
					'filter' => array(
						'email_address' => array(
							'exact' => $email,
						),
					),
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		if ( empty( $response['customers'][0]['id'] ) ) {
			return '';
		}

		return sanitize_text_field( (string) $response['customers'][0]['id'] );
	}

	/**
	 * POST /v2/customers/search.
	 *
	 * When $created_after is null, lists customers without a created_at filter (full sync).
	 *
	 * @param string|null $created_after RFC3339 timestamp (UTC), or null for all.
	 * @param string|null $cursor        Pagination cursor.
	 * @param int         $limit         Page size (1–100).
	 * @return array<string,mixed>|WP_Error
	 */
	private function search_square_customers( $created_after, $cursor = null, $limit = 100 ) {
		$settings = SHC4WC_Plugin::get_settings();
		$api      = new SHC4WC_API( $settings );
		$limit    = max( 1, min( 100, (int) $limit ) );

		$body = array(
			'limit' => $limit,
			'query' => array(
				'sort' => array(
					'field' => 'CREATED_AT',
					'order' => 'ASC',
				),
			),
		);

		/*
		 * Square requires both start_at and end_at for created_at date-range filters.
		 * @see https://developer.squareup.com/docs/customers-api/use-the-api/search-customers
		 */
		if ( is_string( $created_after ) && '' !== $created_after ) {
			$body['query']['filter'] = array(
				'created_at' => array(
					'start_at' => $created_after,
					'end_at'   => gmdate( 'Y-m-d\TH:i:s\Z' ),
				),
			);
		}

		if ( is_string( $cursor ) && '' !== $cursor ) {
			$body['cursor'] = $cursor;
		}

		$response = $api->request( 'POST', '/v2/customers/search', $body );
		if ( is_wp_error( $response ) ) {
			return new WP_Error(
				$response->get_error_code(),
				self::format_square_api_error( $response ),
				$response->get_error_data()
			);
		}

		return $response;
	}

	/**
	 * Turn a Square WP_Error into a merchant-readable message (includes OAuth scope hints).
	 *
	 * @param WP_Error $error API error.
	 * @return string
	 */
	private static function format_square_api_error( $error ) {
		$data   = $error->get_error_data();
		$status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0;
		$payload = is_array( $data ) && isset( $data['data'] ) && is_array( $data['data'] ) ? $data['data'] : array();
		$errors  = isset( $payload['errors'] ) && is_array( $payload['errors'] ) ? $payload['errors'] : array();

		$details = array();
		$codes   = array();
		foreach ( $errors as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			if ( ! empty( $item['code'] ) ) {
				$codes[] = (string) $item['code'];
			}
			if ( ! empty( $item['detail'] ) ) {
				$details[] = (string) $item['detail'];
			} elseif ( ! empty( $item['code'] ) ) {
				$details[] = (string) $item['code'];
			}
		}

		$message = $error->get_error_message();
		if ( ! empty( $details ) ) {
			$message = implode( ' ', $details );
		} elseif ( $status > 0 ) {
			$message = sprintf(
				/* translators: %d: HTTP status code */
				__( 'Square API request failed (HTTP %d).', 'payments-connect-square' ),
				$status
			);
		}

		$auth_codes = array( 'INSUFFICIENT_SCOPES', 'UNAUTHORIZED', 'ACCESS_TOKEN_EXPIRED', 'FORBIDDEN' );
		foreach ( $codes as $code ) {
			if ( in_array( strtoupper( $code ), $auth_codes, true ) || false !== stripos( $message, 'scope' ) ) {
				$message .= ' ' . __( 'Disconnect Square, then Connect again to approve the updated Customer permissions, then retry sync.', 'payments-connect-square' );
				break;
			}
		}

		return $message;
	}

	/**
	 * Create or update a WordPress user from a Square customer object.
	 *
	 * @param array<string,mixed> $customer Square customer.
	 * @return array{status:string,user_id?:int,email?:string}
	 */
	private function upsert_wordpress_customer( array $customer ) {
		$square_id = isset( $customer['id'] ) ? sanitize_text_field( (string) $customer['id'] ) : '';
		$email     = isset( $customer['email_address'] ) ? sanitize_email( (string) $customer['email_address'] ) : '';

		if ( '' === $email || ! is_email( $email ) ) {
			return array( 'status' => 'skipped' );
		}

		$first_name = isset( $customer['given_name'] ) ? sanitize_text_field( (string) $customer['given_name'] ) : '';
		$last_name  = isset( $customer['family_name'] ) ? sanitize_text_field( (string) $customer['family_name'] ) : '';
		$phone      = isset( $customer['phone_number'] ) ? sanitize_text_field( (string) $customer['phone_number'] ) : '';

		$user_id = $this->find_existing_user_id( $email, $square_id );

		if ( $user_id > 0 ) {
			$this->update_existing_user( $user_id, $first_name, $last_name, $phone, $square_id );
			return array(
				'status'  => 'updated',
				'user_id' => $user_id,
				'email'   => $email,
			);
		}

		$new_id = $this->create_customer_user( $email, $first_name, $last_name, $phone, $square_id );
		if ( is_wp_error( $new_id ) || $new_id < 1 ) {
			$this->log(
				'Customer sync could not create user',
				array(
					'email' => $email,
					'error' => is_wp_error( $new_id ) ? $new_id->get_error_message() : 'unknown',
				),
				'error'
			);
			return array( 'status' => 'skipped' );
		}

		return array(
			'status'  => 'created',
			'user_id' => (int) $new_id,
			'email'   => $email,
		);
	}

	/**
	 * Resolve an existing WP user by Square customer ID or email.
	 *
	 * @param string $email     Customer email.
	 * @param string $square_id Square customer ID.
	 * @return int User ID or 0.
	 */
	private function find_existing_user_id( $email, $square_id ) {
		if ( '' !== $square_id ) {
			$by_meta = get_users(
				array(
					'meta_key'   => self::META_SQUARE_CUSTOMER_ID,
					'meta_value' => $square_id,
					'number'     => 1,
					'fields'     => 'ID',
				)
			);
			if ( ! empty( $by_meta[0] ) ) {
				return (int) $by_meta[0];
			}
		}

		$existing = email_exists( $email );
		if ( $existing ) {
			return (int) $existing;
		}

		return 0;
	}

	/**
	 * Create a new WP user with role `customer` (WooCommerce) when available.
	 *
	 * @param string $email     Email.
	 * @param string $first     Given name.
	 * @param string $last      Family name.
	 * @param string $phone     Phone.
	 * @param string $square_id Square customer ID.
	 * @return int|WP_Error
	 */
	private function create_customer_user( $email, $first, $last, $phone, $square_id ) {
		$role = $this->get_customer_role();

		$user_id = wp_insert_user(
			array(
				'user_login'   => $this->unique_login_from_email( $email ),
				'user_email'   => $email,
				'user_pass'    => wp_generate_password( 24, true, true ),
				'first_name'   => $first,
				'last_name'    => $last,
				'display_name' => trim( $first . ' ' . $last ),
				'role'         => $role,
			)
		);

		if ( is_wp_error( $user_id ) ) {
			return $user_id;
		}

		$user_id = (int) $user_id;
		$this->sync_user_profile_meta( $user_id, $first, $last, $phone, $square_id );

		return $user_id;
	}

	/**
	 * Update names/phone/Square ID for an existing user.
	 *
	 * @param int    $user_id   User ID.
	 * @param string $first     Given name.
	 * @param string $last      Family name.
	 * @param string $phone     Phone.
	 * @param string $square_id Square customer ID.
	 */
	private function update_existing_user( $user_id, $first, $last, $phone, $square_id ) {
		$userdata = array( 'ID' => (int) $user_id );

		if ( '' !== $first ) {
			$userdata['first_name'] = $first;
		}
		if ( '' !== $last ) {
			$userdata['last_name'] = $last;
		}
		if ( isset( $userdata['first_name'] ) || isset( $userdata['last_name'] ) ) {
			$current = get_userdata( $user_id );
			$fn      = isset( $userdata['first_name'] ) ? $userdata['first_name'] : ( $current ? $current->first_name : '' );
			$ln      = isset( $userdata['last_name'] ) ? $userdata['last_name'] : ( $current ? $current->last_name : '' );
			$display = trim( $fn . ' ' . $ln );
			if ( '' !== $display ) {
				$userdata['display_name'] = $display;
			}
			wp_update_user( $userdata );
		}

		$this->sync_user_profile_meta( $user_id, $first, $last, $phone, $square_id );
	}

	/**
	 * Map profile fields to WP / WooCommerce billing meta.
	 *
	 * @param int    $user_id   User ID.
	 * @param string $first     First name.
	 * @param string $last      Last name.
	 * @param string $phone     Phone.
	 * @param string $square_id Square customer ID.
	 */
	private function sync_user_profile_meta( $user_id, $first, $last, $phone, $square_id ) {
		if ( '' !== $square_id ) {
			update_user_meta( $user_id, self::META_SQUARE_CUSTOMER_ID, $square_id );
		}

		if ( '' !== $first ) {
			update_user_meta( $user_id, 'first_name', $first );
			update_user_meta( $user_id, 'billing_first_name', $first );
		}
		if ( '' !== $last ) {
			update_user_meta( $user_id, 'last_name', $last );
			update_user_meta( $user_id, 'billing_last_name', $last );
		}
		if ( '' !== $phone ) {
			update_user_meta( $user_id, 'billing_phone', $phone );
		}

		$user = get_userdata( $user_id );
		if ( $user && ! empty( $user->user_email ) ) {
			update_user_meta( $user_id, 'billing_email', sanitize_email( $user->user_email ) );
		}
	}

	/**
	 * Prefer WooCommerce `customer` role; fall back to subscriber.
	 *
	 * @return string
	 */
	private function get_customer_role() {
		if ( get_role( 'customer' ) ) {
			return 'customer';
		}
		return 'subscriber';
	}

	/**
	 * Build a unique user_login from an email address.
	 *
	 * @param string $email Email.
	 * @return string
	 */
	private function unique_login_from_email( $email ) {
		$base = sanitize_user( current( explode( '@', $email ) ), true );
		if ( '' === $base ) {
			$base = 'square_customer';
		}

		$login   = $base;
		$counter = 1;
		while ( username_exists( $login ) ) {
			$login = $base . $counter;
			++$counter;
		}

		return $login;
	}

	/**
	 * Write to WooCommerce / PHP log when debug is enabled.
	 *
	 * @param string               $message Message.
	 * @param array<string,mixed>  $context Context.
	 * @param string               $level   Log level.
	 */
	private function log( $message, $context = array(), $level = 'info' ) {
		$settings = class_exists( 'SHC4WC_Plugin' ) ? SHC4WC_Plugin::get_settings() : array();
		if ( empty( $settings['debug'] ) || 'yes' !== $settings['debug'] ) {
			return;
		}

		$line = $message;
		if ( ! empty( $context ) ) {
			$line .= ' ' . wp_json_encode( $context );
		}

		if ( function_exists( 'wc_get_logger' ) ) {
			wc_get_logger()->log( $level, $line, array( 'source' => 'shc4wc' ) );
			return;
		}

		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		error_log( '[shc4wc] ' . $line );
	}
}
