<?php
/**
 * The background automation engine.
 *
 * @package Ordsync
 */

defined( 'ABSPATH' ) || exit;

/**
 * Nothing here ever runs while a customer is waiting at checkout.
 * The trigger only queues work; Action Scheduler runs it moments later.
 */
class Ordsync_Engine {

	const HOOK_RUN     = 'ordsync_run_destination';
	const GROUP        = 'ordsync';
	const MAX_ATTEMPTS = 3;

	/**
	 * Attempt number => seconds to wait before the next attempt.
	 *
	 * @var array
	 */
	private static $backoff = array(
		1 => 60,
		2 => 300,
	);

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'woocommerce_order_status_changed', array( $this, 'maybe_schedule' ), 10, 4 );
		add_action( self::HOOK_RUN, array( $this, 'run' ), 10, 3 );
	}

	/**
	 * SMART RETRY — is this failure worth trying again?
	 *
	 * Retry only what can plausibly succeed later. Retrying a wrong URL or a
	 * wrong secret three times just burns the server and delays the error the
	 * user actually needs to see.
	 *
	 * @param int $status HTTP status, or 0 for a transport-level failure.
	 * @return bool
	 */
	public static function is_temporary_failure( $status ) {

		$status = (int) $status;

		if ( 0 === $status ) {
			return true; // network, DNS, timeout — the request never landed.
		}

		if ( 429 === $status ) {
			return true; // rate limited — back off and try again.
		}

		if ( $status >= 500 && $status <= 599 ) {
			return true; // the other side broke, not us.
		}

		return false; // 2xx never reaches here; 3xx/4xx are our problem to fix.
	}

	/**
	 * Should this failure be tried again?
	 *
	 * Split out of run() so the decision can be tested on its own. It used
	 * to be two lines inline, and the half that read the HTTP status out of
	 * the WP_Error was wrong for the life of the plugin -- every 404 came
	 * back as 0 and was retried three times. Nothing could catch that,
	 * because run() needs a full WC_Order to reach and no test had one.
	 *
	 * @param true|WP_Error $result  What the destination returned.
	 * @param int           $attempt Attempt number, starting at 1.
	 * @return bool
	 */
	public static function should_retry( $result, $attempt ) {

		if ( true === $result || ! is_wp_error( $result ) ) {
			return false;
		}

		return self::is_temporary_failure( Ordsync_Destination::error_status( $result ) )
			&& (int) $attempt < self::MAX_ATTEMPTS;
	}

	/**
	 * TRIGGER.
	 *
	 * This fires during checkout, so it must be cheap. Queue only, never send.
	 *
	 * @param int      $order_id    Order ID.
	 * @param string   $status_from Old status.
	 * @param string   $status_to   New status.
	 * @param WC_Order $order       Order object.
	 * @return void
	 */
	public function maybe_schedule( $order_id, $status_from, $status_to, $order ) {

		foreach ( Ordsync_Destinations::enabled() as $destination ) {

			$trigger = $destination->trigger_status();

			/*
			 * "any" fires on whatever status the order reaches first. It
			 * cannot double-send: is_done() below already guarantees one
			 * send per order per destination for the life of the order.
			 */
			if ( Ordsync_Settings::TRIGGER_ANY !== $trigger && $trigger !== $status_to ) {
				continue;
			}

			if ( $this->is_done( $order, $destination->id() ) ) {
				continue; // Already sent. Never send the same order twice.
			}

			$this->schedule( $order_id, $destination->id(), 1, 0 );
		}
	}

	/**
	 * Put one destination for one order into the queue.
	 *
	 * @param int    $order_id Order ID.
	 * @param string $dest_key Destination key.
	 * @param int    $attempt  Attempt number, starting at 1.
	 * @param int    $delay    Seconds to wait before running.
	 * @return void
	 */
	public function schedule( $order_id, $dest_key, $attempt = 1, $delay = 0 ) {

		// Action Scheduler ships inside WooCommerce, but never assume.
		if ( ! function_exists( 'as_schedule_single_action' ) ) {
			$this->run( $order_id, $dest_key, $attempt );
			return;
		}

		self::enqueue( $order_id, $dest_key, $attempt, $delay );
	}

	/**
	 * Put one job on the queue.
	 *
	 * Static so callers such as the bulk import can queue work without
	 * constructing an engine, which would register its hooks a second time
	 * and process every order twice.
	 *
	 * @param int    $order_id Order ID.
	 * @param string $dest_key Destination instance id.
	 * @param int    $attempt  Attempt number.
	 * @param int    $delay    Seconds to wait before running.
	 * @return bool Whether anything was queued.
	 */
	public static function enqueue( $order_id, $dest_key, $attempt = 1, $delay = 0 ) {

		if ( ! function_exists( 'as_schedule_single_action' ) ) {
			return false;
		}

		$args = array( (int) $order_id, (string) $dest_key, (int) $attempt );

		if ( function_exists( 'as_has_scheduled_action' )
			&& as_has_scheduled_action( self::HOOK_RUN, $args, self::GROUP ) ) {
			return false; // Already queued.
		}

		as_schedule_single_action( time() + (int) $delay, self::HOOK_RUN, $args, self::GROUP );

		return true;
	}

	/**
	 * WORKER.
	 *
	 * Runs in the background, after the customer is long gone.
	 *
	 * @param int    $order_id Order ID.
	 * @param string $dest_key Destination key.
	 * @param int    $attempt  Attempt number.
	 * @return void
	 */
	public function run( $order_id, $dest_key, $attempt = 1 ) {

		$order = wc_get_order( $order_id );

		if ( ! $order ) {
			Ordsync_Logger::error(
				sprintf( 'Order #%d no longer exists. Skipping %s.', $order_id, $dest_key )
			);
			return;
		}

		$destination = Ordsync_Destinations::get( $dest_key );

		if ( ! $destination || ! $destination->is_enabled() ) {
			return;
		}

		if ( $this->is_done( $order, $dest_key ) ) {
			return;
		}

		$payload = ( new Ordsync_Payload( $order ) )->to_array();

		/**
		 * Last chance to call this order off.
		 *
		 * The free build fires this and nobody listens, so everything sends.
		 * The premium conditional rules hook it to implement "only orders
		 * over 100" and friends. Evaluating here rather than at trigger time
		 * keeps checkout fast: the payload already exists on this side.
		 *
		 * @param bool                $send        Whether to send.
		 * @param Ordsync_Destination $destination Destination about to run.
		 * @param array               $payload     Order payload.
		 */
		if ( ! apply_filters( 'ordsync_should_send', true, $destination, $payload ) ) {
			Ordsync_Logger::info(
				sprintf( 'Order #%d → %s: skipped, conditions not met.', $order_id, $dest_key )
			);
			return;
		}

		$result = $destination->send( $payload );

		if ( true === $result ) {
			$this->mark_done( $order, $dest_key, $attempt );
			Ordsync_Logger::info(
				sprintf( 'Order #%d → %s: success (attempt %d).', $order_id, $dest_key, $attempt )
			);
			return;
		}

		if ( self::should_retry( $result, $attempt ) ) {

			$delay = isset( self::$backoff[ $attempt ] ) ? self::$backoff[ $attempt ] : 300;

			Ordsync_Logger::warning(
				sprintf(
					'Order #%d → %s: temporary failure (%s). Retry %d of %d in %d seconds.',
					$order_id,
					$dest_key,
					$result->get_error_message(),
					$attempt + 1,
					self::MAX_ATTEMPTS,
					$delay
				)
			);

			Ordsync_Events::record_retrying(
				$order_id,
				$dest_key,
				$result->get_error_message(),
				$attempt
			);

			$this->schedule( $order_id, $dest_key, $attempt + 1, $delay );
			return;
		}

		Ordsync_Logger::error(
			sprintf(
				'Order #%d → %s: FAILED permanently after %d attempt(s). %s',
				$order_id,
				$dest_key,
				$attempt,
				$result->get_error_message()
			)
		);

		$this->mark_failed( $order, $dest_key, $result->get_error_message(), $attempt );
	}

	/*
	 * ------------------------------------------------------------------
	 * State. Always through the order object — never update_post_meta(),
	 * which silently does nothing when HPOS is on.
	 * ------------------------------------------------------------------
	 */

	/**
	 * Has this destination already succeeded for this order?
	 *
	 * @param WC_Order $order    Order.
	 * @param string   $dest_key Destination key.
	 * @return bool
	 */
	private function is_done( $order, $dest_key ) {
		return (bool) $order->get_meta( '_ordsync_done_' . $dest_key );
	}

	/**
	 * Record success.
	 *
	 * @param WC_Order $order    Order.
	 * @param string   $dest_key Destination key.
	 * @param int      $attempt  Which attempt succeeded.
	 * @return void
	 */
	private function mark_done( $order, $dest_key, $attempt = 1 ) {
		$order->update_meta_data( '_ordsync_done_' . $dest_key, time() );
		$order->delete_meta_data( '_ordsync_error_' . $dest_key );
		$order->save();

		// Feeds the "last sent" chip on the settings screen.
		Ordsync_Settings::record_success( $dest_key );

		// And one row in the activity table.
		Ordsync_Events::record_sent( $order->get_id(), $dest_key, $attempt );
	}

	/**
	 * Record a permanent failure so the Pro dashboard can offer a resend.
	 *
	 * @param WC_Order $order    Order.
	 * @param string   $dest_key Destination key.
	 * @param string   $message  Error message.
	 * @param int      $attempt  Which attempt gave up.
	 * @return void
	 */
	private function mark_failed( $order, $dest_key, $message, $attempt = 1 ) {
		$order->update_meta_data( '_ordsync_error_' . $dest_key, $message );
		$order->save();

		Ordsync_Settings::record_failure( $dest_key, $message );

		Ordsync_Events::record_failed( $order->get_id(), $dest_key, $message, $attempt );
	}
}
