<?php
/**
 * Registers WooCommerce hooks and Action Scheduler handlers for async syncing
 *
 * @package WooAttioSync
 */

declare( strict_types=1 );

defined( 'ABSPATH' ) || exit;

/**
 * Wires WooCommerce lifecycle events to Action Scheduler for background processing
 */
class WooAttioSync_Sync_Queue {

	/**
	 * Action Scheduler group name used for all plugin actions
	 */
	public const AS_GROUP = 'wcattio';

	/**
	 * wp_options key for the configured sync statuses
	 */
	public const OPTION_SYNC_STATUSES = 'wcattio_sync_statuses';

	/**
	 * Default order statuses (with the 'wc-' prefix) that trigger a sync
	 * when the option has never been saved.
	 */
	public const DEFAULT_SYNC_STATUSES = array( 'wc-processing', 'wc-completed' );

	/**
	 * Maximum number of automatic retry attempts for a transient sync failure
	 * before the plugin gives up and writes a terminal error to the sync log.
	 */
	private const MAX_SYNC_RETRIES = 5;

	/**
	 * Constructor — registers all hooks
	 */
	public function __construct() {
		add_action( 'woocommerce_created_customer', array( $this, 'enqueue_customer_sync' ) );
		add_action( 'woocommerce_order_status_changed', array( $this, 'enqueue_order_status_change' ), 10, 3 );
		// Also cover orders that are created directly in a synced status (admin/manual
		// orders, some gateways) and therefore never fire a clean status transition.
		add_action( 'woocommerce_new_order', array( $this, 'enqueue_new_order' ) );

		// accepted_args = 2 so a rescheduled retry can pass its attempt counter.
		add_action( 'wcattio_sync_order', array( $this, 'process_order_sync' ), 10, 2 );
		add_action( 'wcattio_sync_customer', array( $this, 'process_customer_sync' ), 10, 2 );
	}

	/**
	 * Strip the "wc-" prefix from a WooCommerce order status slug if present.
	 *
	 * @param string $status Raw status slug, e.g. "wc-completed" or "completed".
	 * @return string Status without the prefix, e.g. "completed".
	 */
	public static function strip_wc_prefix( string $status ): string {
		return str_starts_with( $status, 'wc-' ) ? substr( $status, 3 ) : $status;
	}

	/**
	 * Enqueues an async order sync job, skipping if one is already pending
	 *
	 * @param int $order_id WooCommerce order ID.
	 * @return void
	 */
	public function enqueue_order_sync( int $order_id ): void {
		if ( ! function_exists( 'as_enqueue_async_action' ) ) {
			return;
		}

		self::enqueue_unique( 'wcattio_sync_order', array( 'order_id' => $order_id ) );
	}

	/**
	 * Enqueues an async customer sync job, skipping if one is already pending
	 *
	 * @param int $customer_id WooCommerce customer ID.
	 * @return void
	 */
	public function enqueue_customer_sync( int $customer_id ): void {
		if ( ! function_exists( 'as_enqueue_async_action' ) ) {
			return;
		}

		self::enqueue_unique( 'wcattio_sync_customer', array( 'customer_id' => $customer_id ) );
	}

	/**
	 * Enqueue an async action unless the same job is already pending, including
	 * as a scheduled retry. Retries carry an extra 'attempt' arg and Action
	 * Scheduler matches args exactly, so every shape the job can be queued with
	 * must be checked or a status change during a backoff window would enqueue
	 * a duplicate concurrent sync.
	 *
	 * @param string $hook Action Scheduler hook.
	 * @param array  $args Base action args, without 'attempt'.
	 * @return void
	 */
	public static function enqueue_unique( string $hook, array $args ): void {
		if ( as_has_scheduled_action( $hook, $args, self::AS_GROUP ) ) {
			return;
		}

		$retry_args = $args;
		for ( $attempt = 1; $attempt <= self::MAX_SYNC_RETRIES; $attempt++ ) {
			$retry_args['attempt'] = $attempt;
			if ( as_has_scheduled_action( $hook, $retry_args, self::AS_GROUP ) ) {
				return;
			}
		}

		as_enqueue_async_action( $hook, $args, self::AS_GROUP );
	}

	/**
	 * Enqueues an order sync when an order transitions to a relevant status
	 *
	 * @param int    $order_id   WooCommerce order ID.
	 * @param string $old_status Previous status (without the 'wc-' prefix).
	 * @param string $new_status New status (without the 'wc-' prefix).
	 * @return void
	 */
	public function enqueue_order_status_change( int $order_id, string $old_status, string $new_status ): void {
		if ( $this->status_is_synced( $new_status ) ) {
			$this->enqueue_order_sync( $order_id );
		}
	}

	/**
	 * Enqueues an order sync when an order is created directly in a synced status.
	 *
	 * Orders created by the admin or by certain gateways can appear already in a
	 * synced status without ever firing a clean old->new transition, so
	 * woocommerce_order_status_changed alone would miss them. The
	 * as_has_scheduled_action guard in enqueue_order_sync() de-dupes if both hooks fire.
	 *
	 * @param int $order_id WooCommerce order ID.
	 * @return void
	 */
	public function enqueue_new_order( int $order_id ): void {
		if ( ! function_exists( 'wc_get_order' ) ) {
			return;
		}

		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return;
		}

		if ( $this->status_is_synced( self::strip_wc_prefix( $order->get_status() ) ) ) {
			$this->enqueue_order_sync( $order_id );
		}
	}

	/**
	 * Whether a (prefix-stripped) order status is in the configured sync list.
	 *
	 * @param string $status Order status without the 'wc-' prefix.
	 * @return bool
	 */
	private function status_is_synced( string $status ): bool {
		$statuses = self::synced_statuses();

		return ! empty( $statuses ) && in_array( $status, $statuses, true );
	}

	/**
	 * The configured order statuses to sync, with the 'wc-' prefix stripped.
	 *
	 * @return string[]
	 */
	public static function synced_statuses(): array {
		$raw = (array) get_option( self::OPTION_SYNC_STATUSES, self::DEFAULT_SYNC_STATUSES );

		return array_map( array( self::class, 'strip_wc_prefix' ), $raw );
	}

	/**
	 * Action Scheduler handler: processes a queued order sync.
	 *
	 * Transient failures (network, 5xx, 429) are re-queued with a capped backoff.
	 * Action Scheduler does NOT auto-retry a thrown exception, so we must reschedule
	 * explicitly rather than throw. Unexpected PHP errors are swallowed and logged.
	 *
	 * @param int $order_id WooCommerce order ID (passed by Action Scheduler).
	 * @param int $attempt  1-based retry attempt (passed on rescheduled runs).
	 * @return void
	 */
	public function process_order_sync( int $order_id, int $attempt = 1 ): void {
		if ( ! function_exists( 'wc_get_order' ) ) {
			// WooCommerce not fully loaded yet; re-schedule for 1 minute
			as_schedule_single_action(
				time() + 60,
				'wcattio_sync_order',
				array(
					'order_id' => $order_id,
					'attempt'  => $attempt,
				),
				self::AS_GROUP
			);
			return;
		}

		try {
			$client = WooAttioSync_Attio_Client::create();
			if ( is_wp_error( $client ) ) {
				WooAttioSync_Sync_Log::log( 'order', (string) $order_id, 'error', $client->get_error_message() );
				return;
			}

			$sync   = new WooAttioSync_Sync_Order( $client );
			$result = $sync->sync( $order_id );

			if ( is_wp_error( $result ) ) {
				$code = $result->get_error_code();
				if ( 'missing_email' !== $code ) {
					WooAttioSync_Sync_Log::log( 'order', (string) $order_id, 'error', $result->get_error_message() );
				}
				$this->maybe_retry( 'wcattio_sync_order', array( 'order_id' => $order_id ), 'order', (string) $order_id, $result, $attempt );
			}
		} catch ( \Throwable $e ) {
			WooAttioSync_Sync_Log::log( 'order', (string) $order_id, 'error', $e->getMessage() );
		}
	}

	/**
	 * Action Scheduler handler: processes a queued customer sync.
	 *
	 * @param int $customer_id WooCommerce customer ID (passed by Action Scheduler).
	 * @param int $attempt     1-based retry attempt (passed on rescheduled runs).
	 * @return void
	 */
	public function process_customer_sync( int $customer_id, int $attempt = 1 ): void {
		if ( ! class_exists( 'WC_Customer' ) ) {
			as_schedule_single_action(
				time() + 60,
				'wcattio_sync_customer',
				array(
					'customer_id' => $customer_id,
					'attempt'     => $attempt,
				),
				self::AS_GROUP
			);
			return;
		}

		try {
			$client = WooAttioSync_Attio_Client::create();
			if ( is_wp_error( $client ) ) {
				WooAttioSync_Sync_Log::log( 'person', (string) $customer_id, 'error', $client->get_error_message() );
				return;
			}

			$sync   = new WooAttioSync_Sync_Customer( $client );
			$result = $sync->sync( $customer_id, 'customer' );

			if ( is_wp_error( $result ) ) {
				// Same silent skip as order syncs: no email means nothing to sync, not a failure
				if ( 'missing_email' !== $result->get_error_code() ) {
					WooAttioSync_Sync_Log::log( 'person', (string) $customer_id, 'error', $result->get_error_message() );
				}
				$this->maybe_retry( 'wcattio_sync_customer', array( 'customer_id' => $customer_id ), 'person', (string) $customer_id, $result, $attempt );
			}
		} catch ( \Throwable $e ) {
			WooAttioSync_Sync_Log::log( 'person', (string) $customer_id, 'error', $e->getMessage() );
		}
	}

	/**
	 * Reschedule a transient sync failure with a capped backoff, or write a terminal
	 * error once the retry budget is exhausted. Permanent errors are left alone
	 * (they were already logged by the caller). Rate-limit errors honour Retry-After.
	 *
	 * @param string   $hook     Action Scheduler hook to reschedule.
	 * @param array    $args     Base action args (an incremented 'attempt' is added).
	 * @param string   $log_type Sync-log object type for the give-up row.
	 * @param string   $log_id   Sync-log object id for the give-up row.
	 * @param WP_Error $result   The failure returned by the sync.
	 * @param int      $attempt  Current 1-based attempt number.
	 * @return void
	 */
	private function maybe_retry( string $hook, array $args, string $log_type, string $log_id, WP_Error $result, int $attempt ): void {
		// Permanent errors were already logged by the caller and must not retry.
		if ( 'wcattio_rate_limited' !== $result->get_error_code()
			&& ! WooAttioSync_Attio_Client::is_transient_error( $result ) ) {
			return;
		}

		if ( ! self::schedule_retry( $hook, $args, $attempt, self::retry_delay( $result, $attempt ) ) ) {
			WooAttioSync_Sync_Log::log(
				$log_type,
				$log_id,
				'error',
				sprintf(
					/* translators: %d: number of attempts made */
					__( 'Sync failed after %d attempts; it will retry on the next update or bulk sync.', 'harbor-sync-with-attio-for-woocommerce' ),
					$attempt
				)
			);
		}
	}

	/**
	 * Reschedule an action for a capped-backoff retry. Shared by all async handlers
	 * so retry policy lives in one place (Action Scheduler does not auto-retry).
	 *
	 * @param string $hook    Action Scheduler hook to reschedule.
	 * @param array  $args    Base action args (an incremented 'attempt' is added).
	 * @param int    $attempt Current 1-based attempt number.
	 * @param int    $delay   Delay in seconds before the retry runs.
	 * @return bool True when a retry was scheduled; false when the budget is exhausted.
	 */
	public static function schedule_retry( string $hook, array $args, int $attempt, int $delay ): bool {
		if ( $attempt >= self::MAX_SYNC_RETRIES ) {
			return false;
		}

		$args['attempt'] = $attempt + 1;
		as_schedule_single_action( time() + $delay, $hook, $args, self::AS_GROUP );
		return true;
	}

	/**
	 * Retry delay in seconds for a failure: Retry-After for rate limits, otherwise
	 * an exponential backoff for the attempt number.
	 *
	 * @param WP_Error $error   The failure returned by the sync.
	 * @param int      $attempt Current 1-based attempt number.
	 * @return int
	 */
	public static function retry_delay( WP_Error $error, int $attempt ): int {
		if ( 'wcattio_rate_limited' === $error->get_error_code() ) {
			$data = $error->get_error_data();
			return is_array( $data ) ? max( 60, (int) ( $data['retry_after'] ?? 60 ) ) : 60;
		}

		// Exponential backoff, capped at one hour.
		return (int) min( 60 * ( 2 ** max( 0, $attempt - 1 ) ), HOUR_IN_SECONDS );
	}
}
