<?php
/**
 * Tickets Commerce Order
 *
 * @since 5.1.9
 *
 * @package TEC\Tickets\Commerce
 */

namespace TEC\Tickets\Commerce;

use TEC\Common\StellarWP\DB\Database\Exceptions\DatabaseQueryException;
use TEC\Common\StellarWP\DB\DB;
use TEC\Tickets\Commerce\Gateways\Contracts\Gateway_Interface;
use TEC\Tickets\Commerce\Status\Created;
use TEC\Tickets\Commerce\Status\Pending;
use TEC\Tickets\Commerce\Status\Refunded;
use TEC\Tickets\Commerce\Status\Reversed;
use TEC\Tickets\Commerce\Status\Status_Interface;
use TEC\Tickets\Commerce\Utils\Value;
use Tribe__Tickets__Ticket_Object as Ticket_Object;
use WP_Post;

/**
 * Class Order
 *
 * @since 5.1.9
 *
 * @package TEC\Tickets\Commerce
 */
class Order extends Abstract_Order {

	/**
	 * Tickets Commerce Order Post Type slug.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	const POSTTYPE = 'tec_tc_order';

	/**
	 * Meta key for the checkout holding status for a particular order and it's webhooks.
	 *
	 * @since 5.19.3
	 *
	 * @var string
	 */
	public const ON_CHECKOUT_SCREEN_HOLD_META = '_tec_tc_order_on_checkout_screen_hold_timeout';

	/**
	 * Meta key for the checkout status of the order.
	 *
	 * @since 5.18.1
	 *
	 * @var string
	 */
	protected const CHECKOUT_COMPLETED_META = '_tec_tc_checkout_completed';

	/**
	 * Meta key for the order lock status.
	 *
	 * @since 5.18.1
	 *
	 * @var string
	 */
	public const ORDER_LOCK_KEY = 'post_content_filtered';

	/**
	 * Keeping track of the lock id generated during a request.
	 *
	 * Enables to determine if the order has also been locked by current request, so that we can allow edit operations while the order is locked.
	 *
	 * @since 5.18.1
	 *
	 * @var string
	 */
	protected static string $lock_id = '';

	/**
	 * Which meta holds which gateway was used on this order.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $gateway_meta_key = '_tec_tc_order_gateway';

	/**
	 * Which meta holds which gateway order id was used on this order.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $gateway_order_id_meta_key = '_tec_tc_order_gateway_order_id';

	/**
	 * Which meta holds the gateway order object.
	 *
	 * @since 5.24.0
	 *
	 * @var string
	 */
	public const GATEWAY_ORDER_OBJECT_META_KEY = '_tec_tc_order_gateway_order_object';

	/**
	 * Which meta holds the original gateway order id.
	 *
	 * @since 5.24.0
	 *
	 * @var string
	 */
	public const ORIGINAL_GATEWAY_ORDER_ID_META_KEY = '_tec_tc_order_original_gateway_order_id';

	/**
	 * Which meta holds the gateway customer id.
	 *
	 * @since 5.24.0
	 *
	 * @var string
	 */
	public const GATEWAY_CUSTOMER_ID_META_KEY = '_tec_tc_order_gateway_customer_id';

	/**
	 * Which meta holds the latest payload sent to the gateway.
	 *
	 * @since 5.24.0
	 *
	 * @var string
	 */
	public const LATEST_PAYLOAD_HASH_SENT_TO_GATEWAY_META_KEY = '_tec_tc_order_latest_payload_hash_sent_to_gateway';

	/**
	 * Which meta holds the gateway order version.
	 *
	 * @since 5.24.0
	 *
	 * @var string
	 */
	public const GATEWAY_ORDER_VERSION_META_KEY = '_tec_tc_order_gateway_order_version';

	/**
	 * Normally when dealing with the gateways we have a payload from the original creation of the Order on their side
	 * of the API, we should store that whole Payload with this meta key so that this data can be used in the future.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $gateway_payload_meta_key = '_tec_tc_order_gateway_payload';

	/**
	 * Which meta holds the items used to setup this order.
	 *
	 * @since 5.1.9
	 * @since 5.2.0 Updated to use `_tec_tc_order_items` instead of `_tec_tc_order_items`.
	 *
	 * @var string
	 */
	public static $items_meta_key = '_tec_tc_order_items';

	/**
	 * Which meta holds the tickets in a given order, they are added as individual meta items, allowing them to be
	 * selected in a meta query.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $tickets_in_order_meta_key = '_tec_tc_order_tickets_in_order';

	/**
	 * Which meta holds the events in a given order, they are added as individual meta items, allowing them to be
	 * selected in a meta query.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $events_in_order_meta_key = '_tec_tc_order_events_in_order';

	/**
	 * Which meta holds the cart items used to setup this order.
	 *
	 * @since 5.18.0
	 *
	 * @var string
	 */
	public static $subtotal_value_meta_key = '_tec_tc_order_subtotal_value';

	/**
	 * Which meta holds the cart items used to setup this order.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $total_value_meta_key = '_tec_tc_order_total_value';

	/**
	 * Which meta holds the amount unaccounted for in the order.
	 *
	 * @since 5.24.0
	 *
	 * @var string
	 */
	public const META_ORDER_TOTAL_AMOUNT_UNACCOUNTED = '_tec_tc_order_total_amount_unaccounted';

	/**
	 * Which meta holds the total tax amount in the order.
	 *
	 * @since 5.24.0
	 *
	 * @var string
	 */
	public const META_ORDER_TOTAL_TAX = '_tec_tc_order_total_tax';

	/**
	 * Which meta holds the total tip amount in the order.
	 *
	 * @since 5.24.0
	 *
	 * @var string
	 */
	public const META_ORDER_TOTAL_TIP = '_tec_tc_order_total_tip';

	/**
	 * Which meta holds the order created by.
	 *
	 * @since 5.24.0
	 *
	 * @var string
	 */
	public const META_ORDER_CREATED_BY = '_tec_tc_order_created_by';

	/**
	 * Which meta holds the cart items used to setup this order.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $currency_meta_key = '_tec_tc_order_currency';

	/**
	 * Which meta holds the purchaser full name.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $purchaser_full_name_meta_key = '_tec_tc_order_purchaser_full_name';

	/**
	 * Which meta holds the purchaser first name.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $purchaser_first_name_meta_key = '_tec_tc_order_purchaser_first_name';

	/**
	 * Which meta holds the purchaser last name.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $purchaser_last_name_meta_key = '_tec_tc_order_purchaser_last_name';

	/**
	 * Which meta holds the cart items used to setup this order.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $purchaser_email_meta_key = '_tec_tc_order_purchaser_email';

	/**
	 * Which meta holds the purchaser user id.
	 *
	 * @since 5.1.9
	 *
	 * @var string
	 */
	public static $purchaser_user_id_meta_key = '_tec_tc_order_purchaser_user_id';

	/**
	 * Prefix for the log of when a given status was applied.
	 *
	 * @since 5.1.10
	 *
	 * @var string
	 */
	public static $status_log_meta_key_prefix = '_tec_tc_order_status_log';

	/**
	 * Prefix for the Status Flag Action marker meta key.
	 *
	 * @since 5.1.10
	 *
	 * @var string
	 */
	public static $flag_action_status_marker_meta_key_prefix = '_tec_tc_order_fa_marker';

	/**
	 * Meta that holds the hash for this order, which at the time of purchase was unique.
	 *
	 * @since 5.2.0
	 *
	 * @var string
	 */
	public static $hash_meta_key = '_tec_tc_order_hash';

	/**
	 * Meta value for placeholder names.
	 *
	 * @since 5.2.0
	 *
	 * @var string
	 */
	public static $placeholder_name = 'Unknown';

	/**
	 * Register this Class post type into WP.
	 *
	 * @since 5.1.9
	 */
	public function register_post_type() {
		$post_type_args = [
			'label'           => __( 'Orders', 'event-tickets' ),
			'public'          => false,
			'show_ui'         => true,
			'show_in_menu'    => false,
			'query_var'       => false,
			'rewrite'         => false,
			'capability_type' => 'order',
			'map_meta_cap'    => true,
			'capabilities'    => [
				// Meta capabilities.
				'edit_post'          => 'edit_post',
				'read_post'          => 'read_post',
				'delete_post'        => 'delete_post',
				// Primitive capabilities used outside of map_meta_cap().
				'edit_posts'         => 'edit_posts',
				'create_posts'       => 'edit_tc-orders',
				'edit_others_posts'  => 'edit_others_posts',
				'delete_posts'       => 'delete_posts',
				'publish_posts'      => 'publish_tc-orders', // 'publish_posts',
				'read_private_posts' => 'read_private_posts',
			],
			'has_archive'     => false,
			'hierarchical'    => false,
			'supports'        => [ 'title' ],
			'labels'          => [
				'name'                     => __( 'Orders', 'event-tickets' ),
				'singular_name'            => __( 'Order', 'event-tickets' ),
				'add_new'                  => __( 'Add New Order', 'event-tickets' ),
				'add_new_item'             => __( 'Add New Order', 'event-tickets' ),
				'edit_item'                => __( 'Edit Order', 'event-tickets' ),
				'new_item'                 => __( 'New Order', 'event-tickets' ),
				'view_item'                => __( 'View Order', 'event-tickets' ),
				'view_items'               => __( 'View Orders', 'event-tickets' ),
				'search_items'             => __( 'Search Orders', 'event-tickets' ),
				'not_found'                => __( 'No orders found.', 'event-tickets' ),
				'not_found_in_trash'       => __( 'No orders found in Trash.', 'event-tickets' ),
				'all_items'                => __( 'All Orders', 'event-tickets' ),
				'archives'                 => __( 'Order Archives', 'event-tickets' ),
				'attributes'               => __( 'Order Attributes', 'event-tickets' ),
				'insert_into_item'         => __( 'Insert into order', 'event-tickets' ),
				'uploaded_to_this_item'    => __( 'Uploaded to this order', 'event-tickets' ),
				'filter_items_list'        => __( 'Filter orders list', 'event-tickets' ),
				'filter_by_date'           => __( 'Filter by date', 'event-tickets' ),
				'items_list_navigation'    => __( 'Orders list navigation', 'event-tickets' ),
				'items_list'               => __( 'Orders list', 'event-tickets' ),
				'item_published'           => __( 'Order published.', 'event-tickets' ),
				'item_published_privately' => __( 'Order published privately.', 'event-tickets' ),
				'item_reverted_to_draft'   => __( 'Order reverted to draft.', 'event-tickets' ),
				'item_trashed'             => __( 'Order trashed.', 'event-tickets' ),
				'item_scheduled'           => __( 'Order scheduled.', 'event-tickets' ),
				'item_updated'             => __( 'Order updated.', 'event-tickets' ),
				'item_link'                => _x( 'Order Link', 'navigation link block title', 'event-tickets' ),
				'item_link_description'    => _x( 'A link to an order.', 'navigation link block description', 'event-tickets' ),
			],
		];

		/**
		 * Filter the arguments that craft the order post type.
		 *
		 * @since 5.1.9
		 *
		 * @param array $post_type_args Post type arguments, passed to register_post_type()
		 *
		 * @see   register_post_type
		 */
		$post_type_args = apply_filters( 'tec_tickets_commerce_order_post_type_args', $post_type_args );

		register_post_type( static::POSTTYPE, $post_type_args );
	}

	/**
	 * Gets the meta Key for a given Order Status gateway_payload.
	 *
	 * @since 5.1.9
	 *
	 * @param Status\Status_Interface $status
	 *
	 * @return string
	 */
	public static function get_status_log_meta_key( Status\Status_Interface $status ) {
		return static::$status_log_meta_key_prefix . '_' . $status->get_slug();
	}

	/**
	 * Gets the meta Key for a given Order Status gateway_payload.
	 *
	 * @since 5.1.10
	 *
	 * @param Status\Status_Interface $status
	 *
	 * @return string
	 */
	public static function get_gateway_payload_meta_key( Status\Status_Interface $status ) {
		return static::$gateway_payload_meta_key . '_' . $status->get_slug();
	}

	/**
	 * Gets the key for a Flag Action marker for given status and flag.
	 *
	 * @since 5.1.10
	 *
	 * @param string $flag   Which flag we are getting the meta key for.
	 * @param string $status Which status ID we are getting the meta key for.
	 *
	 * @return string
	 */
	public static function get_flag_action_marker_meta_key( $flag, Status\Status_Interface $status ) {
		$prefix = static::$flag_action_status_marker_meta_key_prefix;

		return "{$prefix}:{$status->get_slug()}:{$flag}";
	}

	/**
	 * Modify the status of a given order based on Slug.
	 *
	 * @since 5.1.9
	 * @since 5.18.1 Wrap status update in a transaction to prevent race conditions.
	 *
	 * @param int                     $order_id        Which order ID will be updated.
	 * @param string|Status_Interface $new_status      Which Order Status we are modifying to.
	 * @param array                   $extra_args      Extra repository arguments.
	 *
	 * @return bool
	 */
	public function modify_status( $order_id, $new_status, array $extra_args = [] ): bool {
		if ( ! $new_status instanceof Status\Status_Interface ) {
			$new_status = tribe( Status\Status_Handler::class )->get_by_slug( (string) $new_status );
		}

		if ( ! $new_status instanceof Status\Status_Interface ) {
			return false;
		}

		$required_previous_status = $new_status->required_previous_status();
		if ( $required_previous_status ) {
			$order = tec_tc_get_order( $order_id );
			foreach ( $required_previous_status as $required_status ) {
				// If the required status is present we skip that status.
				if ( isset( $order->status_log[ $required_status->get_slug() ] ) ) {
					continue;
				}

				// Recursively call the modify_status method to ensure all required statuses are met.
				$this->modify_status( $order_id, $required_status, $extra_args );
			}
		}

		// we specifically only lock the order transactions after the required statuses have been met.
		DB::beginTransaction();

		// During these operations - the order should be locked!
		$locked = $this->lock_order( $order_id );

		// If we were unable to lock the order, bail.
		if ( ! $locked ) {
			DB::rollback();

			return false;
		}

		$can_transition = $this->can_transition_to( $new_status, $order_id );

		if ( ! $can_transition ) {
			DB::rollback();

			return $can_transition;
		}

		$args = array_merge( $extra_args, [ 'status' => $new_status->get_wp_slug() ] );

		$updated = tec_tc_orders()
			->by_args(
				[
					'id'                 => $order_id,
					'status'             => 'any',
					self::ORDER_LOCK_KEY => $this->get_lock_id(),
				]
			)
			->set_args( $args )
			->save();

		$this->unlock_order( $order_id );

		DB::commit();

		// After modifying the status we add a meta to flag when it was modified.
		if ( $updated ) {
			add_post_meta( $order_id, static::get_status_log_meta_key( $new_status ), tec_get_current_milliseconds() );
		}

		return (bool) $updated;
	}

	/**
	 * Whether an order's status be transitioned to another status.
	 *
	 * @since 5.18.1
	 *
	 * @param Status_Interface $new_status The new status to transition to.
	 * @param int              $order_id   The order ID to check the transition for.
	 *
	 * @return bool
	 */
	public function can_transition_to( Status_Interface $new_status, int $order_id ): bool {
		$lock_key = self::ORDER_LOCK_KEY;

		try {
			/**
			 * We want to have the fresher current status supporting the lock system if enabled.
			 *
			 * Overcoming object cache and refreshing even if we had retrieved it already during the request.
			 */
			$current_status_wp_slug = DB::get_var(
				DB::prepare(
					"SELECT post_status FROM %i WHERE ID = %d AND $lock_key=%s",
					DB::prefix( 'posts' ),
					$order_id,
					$this->get_lock_id()
				)
			);
		} catch ( DatabaseQueryException $e ) {
			// The query should be failing silently.
			return false;
		}

		if ( ! $current_status_wp_slug ) {
			return false;
		}

		$current_status = tribe( Status\Status_Handler::class )->get_by_wp_slug( $current_status_wp_slug );

		return $current_status->can_change_to( $new_status );
	}

	/**
	 * Prepares an array of cart items ready to go into an Order.
	 *
	 * @since 5.3.0
	 *
	 * @param Cart $cart The cart instance to get items from.
	 *
	 * @return array
	 */
	public function prepare_cart_items_for_order( Cart $cart ) {
		return array_map(
			static function ( $item ) {
				/** @var Value $ticket_value */
				$ticket_value = tribe( Ticket::class )->get_price_value( $item['ticket_id'] );

				if ( null === $ticket_value ) {
					return null;
				}

				$item['price']     = (string) $ticket_value->get_decimal();
				$item['sub_total'] = (string) $ticket_value->sub_total( $item['quantity'] )->get_decimal();

				return $item;
			},
			$cart->get_items_in_cart()
		);
	}

	/**
	 * Creates a order from the items in the cart.
	 *
	 * @since 5.1.9
	 * @since 5.18.1 Now it will only create one order per cart hash. Every next time it will update the existing order.
	 *
	 * @return false|WP_Post
	 * @throws \Tribe__Repository__Usage_Error
	 */
	public function create_from_cart( Gateway_Interface $gateway, $purchaser = null ) {
		$cart = tribe( Cart::class );

		// Prepare the items for the order.
		$items = array_filter(
			array_map(
				static function ( $item ) {
					/** @var Ticket_Object $ticket */
					$ticket = $item['obj'] ?? tribe( Ticket::class )->get_ticket( $item['ticket_id'] );

					// Ensure we have a valid ticket object to work with.
					if ( null === $ticket ) {
						return null;
					}

					// Ensure we have the properties we need on the ticket object.
					if ( ! isset( $ticket->price, $ticket->regular_price ) ) {
						return null;
					}

					$ticket_value         = Value::create( $ticket->price );
					$ticket_regular_value = Value::create( $ticket->regular_price );
					$subtotal_value       = $item['sub_total'] ?? $ticket_value->sub_total( $item['quantity'] );

					return [
						'event_id'          => $item['event_id'] ?? tribe( Ticket::class )->get_related_event_id( $item['ticket_id'] ),
						'extra'             => $item['extra'] ?? [],
						'price'             => $ticket_value->get_decimal(),
						'quantity'          => $item['quantity'],
						'regular_price'     => $ticket_regular_value->get_decimal(),
						'regular_sub_total' => $ticket_regular_value->sub_total( $item['quantity'] )->get_decimal(),
						'sub_total'         => $subtotal_value->get_decimal(),
						'ticket_id'         => $item['ticket_id'],
						'type'              => $item['type'] ?? 'ticket',
					];
				},
				$cart->get_items_in_cart( true )
			)
		);

		// Get the subtotal calculation from the cart.
		$cart_subtotal = Value::create( $cart->get_cart_subtotal() );

		$original_cart_items = $items;

		/**
		 * Filters the cart items before creating an order.
		 *
		 * Allows modification of the cart items array before creating an order,
		 * passing the current subtotal, payment gateway, and purchaser details.
		 *
		 * @since 5.18.0
		 *
		 * @param array             $items     The items in the cart.
		 * @param Value             $subtotal  The calculated subtotal of the cart items.
		 * @param Gateway_Interface $gateway   The payment gateway used for the order.
		 * @param ?array            $purchaser { Purchaser details.
		 *    @type ?int    $purchaser_user_id    The purchaser user ID.
		 *    @type ?string $purchaser_full_name  The purchaser full name.
		 *    @type ?string $purchaser_first_name The purchaser first name.
		 *    @type ?string $purchaser_last_name  The purchaser last name.
		 *    @type ?string $purchaser_email      The purchaser email.
		 * }
		 */
		$items = apply_filters(
			'tec_tickets_commerce_create_order_from_cart_items',
			$items,
			$cart_subtotal,
			$gateway,
			$purchaser
		);

		// Get the total calculation from the cart.
		$cart_total = Value::create( $cart->get_cart_total() );

		/**
		 * Filter the cart total before creating an order.
		 *
		 * @since 5.21.0
		 *
		 * @param Value             $cart_total    The cart total.
		 * @param array             $items         The items in the cart.
		 * @param Value             $cart_subtotal The calculated subtotal of the cart items.
		 * @param Gateway_Interface $gateway       The payment gateway used for the order.
		 * @param ?array            $purchaser     Purchaser details.
		 */
		$cart_total = apply_filters(
			'tec_tickets_commerce_create_order_from_cart_total',
			$cart_total,
			$items,
			$cart_subtotal,
			$gateway,
			$purchaser
		);

		$hash              = $cart->get_cart_hash();
		$existing_order_id = null;

		$order_args = [
			'title'                => $this->generate_order_title( $original_cart_items, $hash ),
			'total_value'          => $cart_total->get_decimal(),
			'subtotal'             => $cart_subtotal->get_decimal(),
			'items'                => $items,
			'gateway'              => $gateway::get_key(),
			'hash'                 => $hash,
			'currency'             => Utils\Currency::get_currency_code(),
			'purchaser_user_id'    => $purchaser['purchaser_user_id'],
			'purchaser_full_name'  => $purchaser['purchaser_full_name'],
			'purchaser_first_name' => $purchaser['purchaser_first_name'],
			'purchaser_last_name'  => $purchaser['purchaser_last_name'],
			'purchaser_email'      => $purchaser['purchaser_email'],
		];

		if ( $hash ) {
			$existing_order_id = tec_tc_orders()->by_args(
				[
					'status' => [ tribe( Pending::class )->get_wp_slug(), tribe( Created::class )->get_wp_slug() ],
					'hash'   => $hash,
				]
			)->first_id();

			if ( ! $existing_order_id || ! is_int( $existing_order_id ) ) {
				$existing_order_id = null;
			}
		}

		$order_args['id'] = $existing_order_id;

		$order = $this->upsert( $gateway, $order_args );

		// We were unable to create the order bail from here.
		if ( ! $order ) {
			return false;
		}

		return $order;
	}

	/**
	 * Filters the values and creates a new Order with Tickets Commerce.
	 *
	 * @since 5.2.0
	 *
	 * @param Gateway_Interface $gateway
	 * @param array             $args
	 *
	 * @return false|WP_Post
	 * @throws \Tribe__Repository__Usage_Error
	 *
	 * @internal Use `upsert` instead.
	 */
	public function create( Gateway_Interface $gateway, $args ) {
		$gateway_key = $gateway::get_key();

		/**
		 * Allows filtering of the order creation arguments for all orders created via Tickets Commerce.
		 *
		 * @since 5.2.0
		 *
		 * @param array             $args
		 * @param Gateway_Interface $gateway
		 */
		$args = apply_filters( "tec_tickets_commerce_order_{$gateway_key}_create_args", $args, $gateway );

		/**
		 * Allows filtering of the order creation arguments for all orders created via Tickets Commerce.
		 *
		 * @since 5.2.0
		 *
		 * @param array             $args
		 * @param Gateway_Interface $gateway
		 */
		$args = apply_filters( 'tec_tickets_commerce_order_create_args', $args, $gateway );

		return tec_tc_orders()->set_args( $args )->create();
	}

	/**
	 * Filters the values and creates a new Order with Tickets Commerce or updates an existing one.
	 *
	 * @since 5.18.1
	 *
	 * @param Gateway_Interface $gateway The gateway to use to create the order.
	 * @param array             $args    The arguments to create the order.
	 *
	 * @return false|WP_Post WP_Post instance on success or false on failure.
	 */
	public function upsert( Gateway_Interface $gateway, array $args ) {
		$gateway_key = $gateway::get_key();

		$existing_order_id = (int) $args['id'] ?? 0;
		unset( $args['id'] );

		/**
		 * Allows filtering of the order upsert arguments for all orders created via Tickets Commerce.
		 *
		 * @since 5.18.1
		 *
		 * @param array             $args    The arguments to create the order.
		 * @param Gateway_Interface $gateway The gateway to use to create the order.
		 */
		$args = apply_filters( "tec_tickets_commerce_order_{$gateway_key}_upsert_args", $args, $gateway );

		/**
		 * Allows filtering of the order upsert arguments for all orders created via Tickets Commerce.
		 *
		 * @since 5.18.1
		 *
		 * @param array             $args    The arguments to create the order.
		 * @param Gateway_Interface $gateway The gateway to use to create the order.
		 */
		$args = apply_filters( 'tec_tickets_commerce_order_upsert_args', $args, $gateway );

		/**
		 * Allows filtering of the existing order ID before "upserting" an order.
		 *
		 * @since TDB
		 *
		 * @param int $existing_order_id The existing order ID.
		 */
		$existing_order_id = (int) apply_filters( 'tec_tickets_commerce_order_upsert_existing_order_id', $existing_order_id );

		if ( ! $existing_order_id || 0 >= $existing_order_id ) {
			return $this->create( $gateway, $args );
		}

		$locked = $this->lock_order( $existing_order_id );

		if ( ! $locked ) {
			$this->unlock_order( $existing_order_id );
			return false;
		}

		/**
		 * Allows filtering of the order update arguments for all orders created via Tickets Commerce.
		 *
		 * @since 5.18.1
		 *
		 * @param array             $args
		 * @param Gateway_Interface $gateway
		 */
		$update_args = apply_filters( "tec_tickets_commerce_order_{$gateway_key}_update_args", $args, $gateway );

		/**
		 * Allows filtering of the order update arguments for all orders created via Tickets Commerce.
		 *
		 * @since 5.18.1
		 *
		 * @param array             $args
		 * @param Gateway_Interface $gateway
		 */
		$update_args = apply_filters( 'tec_tickets_commerce_order_update_args', $update_args, $gateway );

		$updated = tec_tc_orders()
			->by_args(
				[
					'id'                 => $existing_order_id,
					'status'             => 'any',
					self::ORDER_LOCK_KEY => $this->get_lock_id(),
				]
			)
			->set_args( $update_args )
			->save();

		$this->unlock_order( $existing_order_id );

		if ( empty( $updated[ $existing_order_id ] ) ) {
			/**
			 * It seems like the $existing_order_id no longer exists or failed to be updated. Let's create a new one instead.
			 *
			 * BE AWARE: The `$args` variable is not passed through the update filters here since it's going to pass through the create filters.
			 */
			return $this->create( $gateway, $args );
		}

		$order = tec_tc_get_order( $existing_order_id );

		if ( ! $order instanceof WP_Post ) {
			return false;
		}

		return $order;
	}

	/**
	 * Generates a title based on Cart Hash, items in the cart.
	 *
	 * @since 5.1.9
	 *
	 * @param array $items List of events form.
	 *
	 * @return string
	 */
	public function generate_order_title( $items, $hash = null ) {
		$title = [ 'TEC-TC' ];
		if ( $hash ) {
			$title[] = implode( '-', (array) $hash );
		}
		$title[] = 'T';

		$tickets = array_filter( wp_list_pluck( $items, 'ticket_id' ) );
		$title   = array_merge( $title, $tickets );

		return implode( '-', $title );
	}

	/**
	 * Return payment method label for the order.
	 *
	 * @since 5.2.0
	 *
	 * @param int|WP_Post $order Order Object.
	 *
	 * @return string
	 */
	public function get_gateway_label( $order ) {
		if ( is_numeric( $order ) ) {
			$order = tec_tc_get_order( $order );
		}

		if ( empty( $order->gateway ) ) {
			return null;
		}

		$gateway = tribe( Gateways\Manager::class )->get_gateway_by_key( $order->gateway );
		if ( empty( $gateway ) ) {
			return null;
		}

		return $gateway::get_label();
	}

	/**
	 * Redirects to the source post after a recoverable (logic) error.
	 *
	 * @since 5.1.9
	 *
	 * @param int  $error_code The current error code.
	 * @param bool $redirect   Whether to really redirect or not.
	 * @param int  $post_id    A post ID.
	 *
	 * @todo  Deprecate tpp_error
	 *
	 * @see   \Tribe__Tickets__Commerce__PayPal__Errors for error codes translations.
	 * @todo  Determine if redirecting should be something relegated to some other method, and here we only generate
	 *        generate the order/Attendees.
	 */
	protected function redirect_after_error( $error_code, $redirect, $post_id ) {
		$url = add_query_arg( 'tpp_error', $error_code, get_permalink( $post_id ) );
		if ( $redirect ) {
			wp_redirect( esc_url_raw( $url ) );
		}
		tribe_exit();
	}

	/**
	 * Loads an order object with information about its attendees
	 *
	 * @since 5.2.0
	 *
	 * @param WP_Post $order the order object.
	 *
	 * @return WP_Post|WP_Post[]
	 */
	public function get_attendees( WP_Post $order ) {
		$order->attendees = tribe( Module::class )->get_attendees_by_order_id( $order->ID );

		if ( empty( $order->attendees ) ) {
			$order->attendees = [
				'name'  => get_post_meta( $order->ID, static::$purchaser_full_name_meta_key, true ),
				'email' => get_post_meta( $order->ID, static::$purchaser_email_meta_key, true ),
			];

			return [ $order ];
		}

		return $order;
	}

	/**
	 * Returns the events associated with the order.
	 *
	 * @since 5.13.3
	 *
	 * @param WP_Post|int $order The order object or ID.
	 *
	 * @return WP_Post[]
	 */
	public function get_events( $order ): array {
		$order = tec_tc_get_order( $order );

		if ( ! $order instanceof WP_Post ) {
			return [];
		}

		$events = $order->events_in_order ?? [];

		if ( empty( $events ) ) {
			return [];
		}

		return array_filter(
			array_map( 'get_post', $events ),
			function ( $event ) {
				return $event instanceof WP_Post;
			}
		);
	}

	/**
	 * Returns the total value of the order.
	 *
	 * @since 5.13.3
	 *
	 * @param WP_Post|int $order    The order object or ID.
	 * @param bool        $original Whether to get the original value or the current value.
	 *
	 * @return ?string
	 */
	public function get_value( $order, $original = false ): ?string {
		$order = tec_tc_get_order( $order );

		if ( ! $order instanceof WP_Post ) {
			return null;
		}

		$reversed = tribe( Reversed::class )->get_wp_slug();
		$refunded = tribe( Refunded::class )->get_wp_slug();
		if ( ! in_array( $order->post_status, [ $reversed, $refunded ], true ) ) {
			$regular = 0;
			$total   = 0;

			foreach ( $order->items as $cart_item ) {
				$cart_item_type = $cart_item['type'] ?? 'ticket';
				if ( 'ticket' !== $cart_item_type ) {
					continue;
				}

				$regular += $cart_item['regular_sub_total'] ?? 0;
				$total   += $cart_item['sub_total'] ?? 0;
			}

			// Backwards compatible. We didn't use to store regular, so in most installs this is going to be diff cause regular is gonna be 0 mostly.
			if ( $total !== $regular && $regular > $total ) {
				return Value::create( $original ? $regular : $total )->get_currency();
			}

			return $order->total_value->get_currency();
		}

		if ( empty( $order->gateway_payload['refunded'] ) ) {
			// The item was refunded but we don't know anything about it.
			return $order->total_value->get_currency();
		}

		$refunds = $order->gateway_payload['refunded'];

		/**
		 * Filters the refunded amount of an order.
		 *
		 * @since 5.24.0
		 *
		 * @param ?int    $refunded The refunded amount.
		 * @param array   $refunds The refunds.
		 * @param WP_Post $order The order.
		 */
		$refunded = (int) apply_filters( "tec_tickets_commerce_order_{$order->gateway}_get_value_refunded", null, $refunds, $order );

		/**
		 * Filters the refunded amount of an order.
		 *
		 * @since 5.24.0
		 *
		 * @param ?int    $refunded The refunded amount.
		 * @param array   $refunds The refunds.
		 * @param WP_Post $order The order.
		 */
		$refunded = (int) apply_filters( 'tec_tickets_commerce_order_get_value_refunded', $refunded ? $refunded : null, $refunds, $order );

		/**
		 * Filters the captured amount of an order.
		 *
		 * @since 5.24.0
		 *
		 * @param ?int    $captured The captured amount.
		 * @param array   $refunds The refunds.
		 * @param WP_Post $order The order.
		 */
		$total = (int) apply_filters( "tec_tickets_commerce_order_{$order->gateway}_get_value_captured", (int) ( 100 * $order->total_value->get_decimal() ), $refunds, $order );

		/**
		 * Filters the captured amount of an order.
		 *
		 * @since 5.24.0
		 *
		 * @param int     $captured The captured amount.
		 * @param array   $refunds The refunds.
		 * @param WP_Post $order The order.
		 */
		$total = (int) apply_filters( 'tec_tickets_commerce_order_get_value_captured', $total, $refunds, $order );

		if ( ! ( $total && $refunded ) ) {
			return $order->total_value->get_currency();
		}

		$total_value = $total - $refunded;

		return Value::create( ( $original ? $total : $total_value ) / 100 )->get_currency();
	}

	/**
	 * Returns the total value of an order item.
	 *
	 * @since 5.13.3
	 *
	 * @param array $item     The order object or ID.
	 * @param bool  $original Whether to get the original value or the current value.
	 *
	 * @return ?string
	 */
	public function get_item_value( $item, $original = false ): ?string {
		$current = $item['price'];
		$regular = $item['regular_price'] ?? $current;

		return $original ? Value::create( $regular )->get_currency() : Value::create( $current )->get_currency();
	}

	/**
	 * Returns the Ticket ID that is associated with the attendee
	 *
	 * @since 5.2.0
	 *
	 * @param WP_Post $attendee the attendee object.
	 *
	 * @return mixed
	 */
	public function get_ticket_id( WP_Post $attendee ) {
		return get_post_meta( $attendee->ID, static::$tickets_in_order_meta_key, true );
	}

	/**
	 * Check if the order is of valid type.
	 *
	 * @since 5.2.0
	 *
	 * @param int|WP_Post $order The Order object to check.
	 *
	 * @return bool
	 */
	public static function is_valid( $order ) {
		$order = get_post( $order );

		if ( ! $order ) {
			return false;
		}

		return static::POSTTYPE === $order->post_type;
	}

	/**
	 * Get the order associated with a given gateway order id.
	 *
	 * @since 5.3.0
	 * @since 5.14.0 Update to fetch latest order in the case of multiple orders with the same gateway order id.
	 *
	 * @param string $gateway_order_id The gateway order id.
	 *
	 * @return mixed|WP_Post|null
	 */
	public function get_from_gateway_order_id( $gateway_order_id ) {
		return tec_tc_orders()->by_args(
			[
				'order_by'         => 'ID',
				'order'            => 'DESC',
				'status'           => 'any',
				'gateway_order_id' => $gateway_order_id,
			]
		)->first();
	}

	/**
	 * Get the IDs of orders associated with a given gateway order id.
	 *
	 * @since 5.24.0
	 *
	 * @param string $gateway_order_id The gateway order id.
	 *
	 * @return int[]
	 */
	public function get_order_ids_from_gateway_order_id( $gateway_order_id ): array {
		return (array) tec_tc_orders()->by_args(
			[
				'order_by'         => 'ID',
				'order'            => 'DESC',
				'status'           => 'any',
				'gateway_order_id' => $gateway_order_id,
			]
		)->get_ids( false );
	}

	/**
	 * Lock an order to prevent it from being modified.
	 *
	 * @since 5.18.1
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return bool Whether the order was locked.
	 */
	public function lock_order( int $order_id ): bool {
		$this->generate_lock_id();

		try {
			$lock_key = self::ORDER_LOCK_KEY;

			$result = (bool) DB::query(
				DB::prepare(
					"UPDATE %i set $lock_key = %s where ID = $order_id and $lock_key = ''",
					DB::prefix( 'posts' ),
					$this->get_lock_id()
				)
			);

			wp_cache_delete( $order_id, 'posts' );

			/**
			 * Fires after an order is attempted to be locked.
			 *
			 * @since 5.18.1
			 *
			 * @param bool   $result   Whether the order was locked.
			 * @param int    $order_id The order ID.
			 * @param string $lock_id  The lock ID.
			 */
			do_action( 'tec_tickets_commerce_order_locked', $result, $order_id, $this->get_lock_id() );

			return $result;
		} catch ( DatabaseQueryException $e ) {
			return false;
		}
	}

	/**
	 * Unlock an order to allow it to be modified.
	 *
	 * @since 5.18.1
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return bool Whether the order was unlocked.
	 */
	public function unlock_order( int $order_id ): bool {
		$lock_key = self::ORDER_LOCK_KEY;
		try {
			$result = (bool) DB::query(
				DB::prepare(
					"UPDATE %i set $lock_key = '' where ID = $order_id",
					DB::prefix( 'posts' )
				)
			);

			wp_cache_delete( $order_id, 'posts' );

			/**
			 * Fires after an order is attempted to be unlocked.
			 *
			 * @since 5.18.1
			 *
			 * @param bool   $result   Whether the order was unlocked.
			 * @param int    $order_id The order ID.
			 * @param string $lock_id  The lock ID.
			 */
			do_action( 'tec_tickets_commerce_order_unlocked', $result, $order_id, $this->get_lock_id() );

			$this->reset_lock_id();

			return $result;
		} catch ( DatabaseQueryException $e ) {
			return false;
		}
	}

	/**
	 * Get the lock ID.
	 *
	 * @since 5.18.1
	 *
	 * @return string The lock ID.
	 */
	public function get_lock_id(): string {
		return self::$lock_id;
	}

	/**
	 * Reset the lock ID.
	 *
	 * Usually after unlocking an order.
	 *
	 * @since 5.18.1
	 */
	public function reset_lock_id(): void {
		self::$lock_id = '';
	}

	/**
	 * Generate a lock ID.
	 *
	 * @since 5.18.1
	 *
	 * @return string The lock ID.
	 */
	public function generate_lock_id(): string {
		self::$lock_id = uniqid( '_order_lock', true );

		return self::$lock_id;
	}

	/**
	 * Get whether the order is locked.
	 *
	 * @since 5.18.1
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return bool Whether the order is locked.
	 */
	public function is_order_locked( int $order_id ): bool {
		$lock_key = self::ORDER_LOCK_KEY;

		try {
			/**
			 * Filters whether the order is locked.
			 *
			 * @since 5.18.1
			 *
			 * @param bool $is_locked Whether the order is locked.
			 * @param int  $order_id  The order ID.
			 */
			return apply_filters(
				'tec_tickets_commerce_order_is_locked',
				(bool) DB::get_var(
					DB::prepare(
						"SELECT $lock_key FROM %i WHERE ID = $order_id",
						DB::prefix( 'posts' )
					)
				),
				$order_id
			);
		} catch ( DatabaseQueryException $e ) {
			return false;
		}
	}

	/**
	 * Get whether the order has its checkout completed.
	 *
	 * @since 5.18.1
	 * @deprecated 5.19.3 In favor of using checkout on_screen hold timeouts.
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return bool Whether the checkout is completed.
	 */
	public function is_checkout_completed( int $order_id ): bool {
		try {
			/**
			 * Filters whether the checkout is completed.
			 *
			 * Does a direct query to overcome object cache.
			 *
			 * @since 5.18.1
			 *
			 * @param bool $is_completed Whether the checkout is completed.
			 * @param int  $order_id     The order ID.
			 */
			return apply_filters(
				'tec_tickets_commerce_order_is_checkout_completed',
				(bool) DB::get_var(
					DB::prepare(
						'SELECT meta_value FROM %i WHERE post_id = %d AND meta_key = %s',
						DB::prefix( 'postmeta' ),
						$order_id,
						static::CHECKOUT_COMPLETED_META
					)
				),
				$order_id
			);
		} catch ( DatabaseQueryException $e ) {
			return false;
		}
	}

	/**
	 * Mark an order's checkout as completed.
	 *
	 * @since 5.18.1
	 * @deprecated 5.19.3 In favor of using checkout on_screen hold timeouts.
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return bool Whether the checkout was marked as completed and the async action was scheduled.
	 */
	public function checkout_completed( int $order_id ): bool {
		$result = (bool) update_post_meta( $order_id, static::CHECKOUT_COMPLETED_META, true );

		if ( ! $result ) {
			return false;
		}

		/**
		 * Fires after an order's checkout is marked as completed.
		 *
		 * @since 5.18.1
		 *
		 * @param int $order_id The order ID.
		 */
		do_action( 'tec_tickets_commerce_order_checkout_completed', $order_id );

		$args = [
			'order_id' => $order_id,
			'try'      => 0,
		];

		if ( as_has_scheduled_action( 'tec_tickets_commerce_async_webhook_process', $args, 'tec-tickets-commerce-webhooks' ) ) {
			return true;
		}

		return (bool) as_enqueue_async_action(
			'tec_tickets_commerce_async_webhook_process',
			$args,
			'tec-tickets-commerce-webhooks'
		);
	}

	/**
	 * Given an order ID, check if the order is on checkout screen hold.
	 *
	 * @param int $order_id Which order we are checking.
	 *
	 * @return bool
	 */
	public function has_on_checkout_screen_hold( int $order_id ): bool {
		$on_screen_hold = get_post_meta( $order_id, static::ON_CHECKOUT_SCREEN_HOLD_META, true );

		// This is here because we previously stored the timestamp instead of DB milliseconds.
		if ( is_numeric( $on_screen_hold ) ) {
			$on_screen_hold = 0;
		}

		/**
		 * Filters whether the order is on checkout screen hold.
		 * This is used to determine if the order is still on the checkout screen.
		 *
		 * @since 5.19.3
		 *
		 * @param bool $is_on_screen_hold Whether the order is on the checkout screen hold.
		 * @param int  $order_id         The order ID.
		 */
		return (bool) apply_filters( 'tec_tickets_commerce_order_has_on_checkout_screen_hold', $on_screen_hold > tec_get_current_milliseconds(), $order_id );
	}

	/**
	 * Get the default on checkout screen hold timeout.
	 *
	 * @since 5.19.3
	 *
	 * @return int
	 */
	protected function get_default_on_checkout_screen_hold_timeout(): int {
		/**
		 * Filters the default on checkout screen hold timeout.
		 *
		 * @since 5.19.3
		 *
		 * @param int $timeout The default timeout.
		 */
		return (int) apply_filters( 'tec_tickets_commerce_order_on_checkout_screen_hold_timeout', MINUTE_IN_SECONDS * 5 );
	}

	/**
	 * Set an order on checkout screen hold.
	 *
	 * @param int $order_id Which order we are setting.
	 *
	 * @return bool
	 */
	public function set_on_checkout_screen_hold( int $order_id ): bool {
		$seconds        = $this->get_default_on_checkout_screen_hold_timeout();
		$on_screen_hold = tec_get_current_milliseconds( new \DateInterval( "PT{$seconds}S" ) );

		$updated = (bool) update_post_meta( $order_id, static::ON_CHECKOUT_SCREEN_HOLD_META, $on_screen_hold );

		if ( ! $updated ) {
			return false;
		}

		/**
		 * Fires after an order is marked as on checkout screen hold.
		 *
		 * @since 5.18.1
		 *
		 * @param int $order_id The order ID.
		 */
		do_action( 'tec_tickets_commerce_order_on_checkout_screen_hold_set', $order_id, $on_screen_hold );

		$args = [
			'order_id' => $order_id,
			'try'      => 0,
		];

		if ( as_has_scheduled_action( 'tec_tickets_commerce_async_webhook_process', $args, 'tec-tickets-commerce-webhooks' ) ) {
			return true;
		}

		return (bool) as_schedule_single_action(
			tec_from_milliseconds_to_timestamp( $on_screen_hold ) + MINUTE_IN_SECONDS, // We schedule the action to run after the timeout.
			'tec_tickets_commerce_async_webhook_process',
			$args,
			'tec-tickets-commerce-webhooks'
		);
	}
	/**
	 * Delete an order on checkout screen hold.
	 *
	 * @since 5.19.3
	 *
	 * @param int $order_id Which order we are setting.
	 *
	 * @return bool
	 */
	public function remove_on_checkout_screen_hold( int $order_id ): bool {
		$updated = (bool) delete_post_meta( $order_id, static::ON_CHECKOUT_SCREEN_HOLD_META );

		if ( ! $updated ) {
			return false;
		}

		/**
		 * Fires after an order has its checkout screen hold removed.
		 *
		 * @since 5.19.3
		 *
		 * @param int $order_id The order ID.
		 */
		do_action( 'tec_tickets_commerce_order_on_checkout_screen_hold_remove', $order_id );

		$args = [
			'order_id' => $order_id,
			'try'      => 0,
		];

		if ( as_has_scheduled_action( 'tec_tickets_commerce_async_webhook_process', $args, 'tec-tickets-commerce-webhooks' ) ) {
			return true;
		}

		return (bool) as_schedule_single_action(
			time(),
			'tec_tickets_commerce_async_webhook_process',
			$args,
			'tec-tickets-commerce-webhooks'
		);
	}

	/**
	 * Generate a hashed key for the order for public view.
	 *
	 * @since 5.19.1
	 *
	 * @param string $hash The order cart hash.
	 * @param string $email The email of the purchaser.
	 *
	 * @return string The order hash key.
	 */
	public function generate_order_key( string $hash, string $email ): string {
		$time  = time();
		$email = sanitize_email( $email );
		$hash  = empty( $hash ) ? wp_generate_password() : $hash;

		return substr( md5( $hash . $email . $time ), 0, 12 );
	}

	/**
	 * Get the order items.
	 *
	 * @since 5.24.0
	 *
	 * @param WP_Post $order The order post object.
	 *
	 * @return array The order items.
	 */
	public function get_order_items( WP_Post $order ): array {
		return array_filter( array_merge( $order->items ?? [], $order->discounts ?? [], $order->service_charges ?? [] ) );
	}

	/**
	 * Get the order total value.
	 *
	 * @since 5.24.0
	 *
	 * @param WP_Post $order The order post object.
	 *
	 * @return Value The order total value.
	 */
	public function get_orders_total_value( WP_Post $order ): Value {
		return $this->get_value_total( $this->get_order_items( $order ) );
	}

	/**
	 * Get the order created by.
	 *
	 * @since 5.24.0
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return string The order created by.
	 */
	public static function get_created_by( int $order_id ): string {
		$created_by = trim( (string) get_post_meta( $order_id, self::META_ORDER_CREATED_BY, true ) );

		if ( empty( $created_by ) ) {
			return '';
		}

		switch ( $created_by ) {
			case 'square-pos':
				$translated = esc_html__( 'Square POS', 'event-tickets' );
				break;
			default:
				$translated = $created_by;
				break;
		}

		/**
		 * Filters the order created by.
		 *
		 * @since 5.24.0
		 *
		 * @param string $created_by The order created by.
		 */
		return apply_filters( 'tec_tickets_commerce_order_created_by', $translated, $order_id );
	}
}
