Current File : /home/digitaw/www/wp-content/plugins/event-tickets/src/Tickets/Seating/Service/Layouts.php
<?php
/**
 * The service component used to fetch the Layouts from the service.
 *
 * @since 5.16.0
 *
 * @package TEC\Controller\Service;
 */

namespace TEC\Tickets\Seating\Service;

use TEC\Common\StellarWP\Arrays\Arr;
use TEC\Common\StellarWP\DB\DB;
use TEC\Tickets\Seating\Admin\Events\Associated_Events;
use TEC\Tickets\Seating\Logging;
use TEC\Tickets\Seating\Meta;
use TEC\Tickets\Seating\Tables\Layouts as Layouts_Table;
use TEC\Tickets\Seating\Admin\Tabs\Layout_Card;
use TEC\Tickets\Seating\Tables\Seat_Types as Seat_Types_Table;
use Tribe__Tickets__Global_Stock as Global_Stock;
use Tribe__Tickets__Main as Tickets;

/**
 * Class Layouts.
 *
 * @since 5.16.0
 *
 * @package TEC\Controller\Service;
 */
class Layouts {
	use oAuth_Token;
	use Logging;

	/**
	 * The URL to the service used to fetch the layouts from the backend.
	 *
	 * @since 5.16.0
	 *
	 * @var string
	 */
	private string $service_fetch_url;

	/**
	 * Layouts constructor.
	 *
	 * @since 5.16.0
	 *
	 * @param string $backend_base_url The base URL of the service from the site backend.
	 */
	public function __construct( string $backend_base_url ) {
		$this->service_fetch_url = rtrim( $backend_base_url, '/' ) . '/api/v1/layouts';
	}

	/**
	 * Inserts multiple rows from the service into the table.
	 *
	 * @since 5.16.0
	 *
	 * @param array<array{ id?: string, name?: string, seats?: int, mapId?: string, createdDate?: string, screenshotUrl?: string}> $service_rows The rows to insert.
	 *
	 * @return bool|int The number of rows affected, or `false` on failure.
	 */
	public static function insert_rows_from_service( array $service_rows ) {
		$valid = array_reduce(
			$service_rows,
			static function ( array $valid, array $service_row ): array {
				if ( ! isset(
					$service_row['id'],
					$service_row['name'],
					$service_row['seats'],
					$service_row['mapId'],
					$service_row['createdDate'],
					$service_row['screenshotUrl']
				) ) {
					return $valid;
				}

				$created_date_in_ms = $service_row['createdDate'];
				$created_date       = gmdate( 'Y-m-d H:i:s', intval( $created_date_in_ms / 1000 ) );

				$valid[] = [
					'id'             => $service_row['id'],
					'name'           => $service_row['name'],
					'seats'          => $service_row['seats'],
					'map'            => $service_row['mapId'],
					'screenshot_url' => $service_row['screenshotUrl'],
					'created_date'   => $created_date,
				];

				return $valid;
			},
			[]
		);

		if ( ! count( $valid ) ) {
			return 0;
		}

		return Layouts_Table::insert_many( $valid );
	}

	/**
	 * Returns the layouts in option format.
	 *
	 * @since 5.16.0
	 *
	 * @return array<array{id: string, name: string, seats: int}> The layouts in option format.
	 */
	public function get_in_option_format(): array {
		if ( ! $this->update() ) {
			return [];
		}

		$layouts = wp_cache_get( 'option_format_layouts', 'tec-tickets-seating' );

		if ( ! ( $layouts && is_array( $layouts ) ) ) {
			$layouts = [];
			foreach ( Layouts_Table::get_all() as $row ) {
				$layouts[] = [
					'id'    => $row['id'],
					'name'  => $row['name'],
					'seats' => $row['seats'],
				];
			}

			wp_cache_set(
				'option_format_layouts',
				$layouts,
				'tec-tickets-seating',
				self::update_transient_expiration() // phpcs:ignore
			);
		}

		return $layouts;
	}

	/**
	 * Fetches all the Layouts from the database.
	 *
	 * @since 5.16.0
	 *
	 * @return Layout_Card[] Array of layout card objects.
	 */
	public function get_in_card_format() {
		if ( ! $this->update() ) {
			return [];
		}

		$mem_key      = 'option_layout_card_objects';
		$cache        = tribe_cache();
		$layout_cards = $cache[ $mem_key ];

		if ( ! ( $layout_cards && is_array( $layout_cards ) ) ) {
			$layout_cards = [];
			foreach ( Layouts_Table::get_all() as $row ) {
				$layout_cards[] = new Layout_Card(
					$row['id'],
					$row['name'],
					$row['map'],
					$row['seats'],
					$row['screenshot_url']
				);
			}

			$cache[ $mem_key ] = $layout_cards;
		}

		return $layout_cards;
	}

	/**
	 * Updates the layouts from the service by updating the caches and custom tables.
	 *
	 * @since 5.16.0
	 *
	 * @param bool $force If true, the layouts will be updated even if they are up-to-date.
	 *
	 * @return bool Whether the layouts are up-to-date or not.
	 */
	public function update( bool $force = false ) {
		$updater = new Updater( $this->service_fetch_url, self::update_transient_name(), self::update_transient_expiration() );

		return $updater->check_last_update( $force )
						->update_from_service( [ $this, 'invalidate_cache' ] )
						->store_fetched_data( [ $this, 'insert_rows_from_service' ] );
	}

	/**
	 * Returns the transient name used to store the last update time.
	 *
	 * @since 5.16.0
	 *
	 * @return string The transient name used to store the last update time.
	 */
	public static function update_transient_name(): string {
		return 'tec_tickets_seating_layouts_last_update';
	}

	/**
	 * Returns the expiration time in seconds.
	 *
	 * @since 5.16.0
	 *
	 * @return int The expiration time in seconds.
	 */
	public static function update_transient_expiration() {
		return 12 * HOUR_IN_SECONDS;
	}

	/**
	 * Returns the number of events associated with the layout.
	 *
	 * @since 5.16.0
	 *
	 * @param string $layout_id The ID of the layout.
	 *
	 * @return int The number of posts associated with the layout.
	 */
	public static function get_associated_posts_by_id( string $layout_id ): int {
		global $wpdb;
		$ticketable_post_types = tribe_get_option( 'ticket-enabled-post-types', [] );

		if ( empty( $ticketable_post_types ) ) {
			return 0;
		}

		$post_types = DB::prepare(
			implode( ', ', array_fill( 0, count( $ticketable_post_types ), '%s' ) ),
			...$ticketable_post_types
		);

		$supported_status_list = Associated_Events::get_supported_status_list();

		$status_list = DB::prepare(
			implode( ', ', array_fill( 0, count( $supported_status_list ), '%s' ) ),
			...$supported_status_list
		);

		try {
			$count = DB::get_var(
				DB::prepare(
					"SELECT COUNT(*) FROM %i AS posts
					LEFT JOIN %i AS layout_meta
					ON posts.ID = layout_meta.post_id
					WHERE posts.post_type IN ({$post_types})
					AND posts.post_status IN ({$status_list})
					AND layout_meta.meta_key = %s
					AND layout_meta.meta_value = %s",
					$wpdb->posts,
					$wpdb->postmeta,
					Meta::META_KEY_LAYOUT_ID,
					$layout_id
				)
			);
		} catch ( \Exception $e ) {
			$count = 0;
		}

		return $count;
	}

	/**
	 * Invalidates all the caches and custom tables storing information about Layouts.
	 *
	 * @since 5.16.0
	 *
	 * @param boolean $truncate Whether to truncate the caches and custom tables storing information about Layouts.
	 *
	 * @return bool Whether the caches and custom tables storing information about Layouts were invalidated.
	 */
	public static function invalidate_cache( bool $truncate = true ): bool {
		delete_transient( self::update_transient_name() );
		delete_transient( Seat_Types::update_transient_name() );
		wp_cache_delete( 'option_format_layouts', 'tec-tickets-seating' );
		$invalidated = true;

		if ( $truncate ) {
			$invalidated &= tribe( Layouts_Table::class )->empty_table() !== false &&
							tribe( Seat_Types_Table::class )->empty_table() !== false;
		}

		/**
		 * Fires after the caches and custom tables storing information about Layouts have been
		 * invalidated.
		 *
		 * @since 5.16.0
		 *
		 * @param boolean $truncate Whether to truncate the caches and custom tables storing information about Layouts.
		 */
		do_action( 'tec_tickets_seating_invalidate_layouts_cache', $truncate );

		return $invalidated;
	}

	/**
	 * Returns the URL to delete a layout.
	 *
	 * @since 5.16.0
	 *
	 * @param string $layout_id The UUID of the layout to delete.
	 * @param string $map_id    The UUID of the map the layout belongs to.
	 *
	 * @return string The URL to delete the layout.
	 */
	public function get_delete_url( string $layout_id, string $map_id ): string {
		return add_query_arg(
			[
				'layoutId' => $layout_id,
				'mapId'    => $map_id,
			],
			$this->service_fetch_url
		);
	}

	/**
	 * Deletes a layout from the service.
	 *
	 * @since 5.16.0
	 *
	 * @param string $layout_id The ID of the layout to delete.
	 * @param string $map_id    The Map ID of the layout to delete.
	 *
	 * @return bool True on success, false on failure.
	 */
	public function delete( string $layout_id, string $map_id ): bool {
		$url = $this->get_delete_url( $layout_id, $map_id );

		$args = [
			'method'  => 'DELETE',
			'headers' => [
				'Authorization' => 'Bearer ' . $this->get_oauth_token(),
				'Content-Type'  => 'application/json',
			],
		];

		$response = wp_remote_request( $url, $args );
		$code     = wp_remote_retrieve_response_code( $response );

		if ( ! is_wp_error( $response ) && 200 === $code ) {
			self::invalidate_cache();
			Maps::invalidate_cache();
			return true;
		}

		$this->log_error(
			'Failed to delete the layout from the service.',
			[
				'source'   => __METHOD__,
				'code'     => $code,
				'url'      => $url,
				'response' => $response,
			]
		);

		return false;
	}

	/**
	 * Returns the URL to add a new layout.
	 *
	 * @since 5.16.0
	 *
	 * @param string $map_id The ID of the map to add the layout to.
	 *
	 * @return string The URL to add a new layout.
	 */
	public function get_add_url( string $map_id ): string {
		return add_query_arg(
			[
				'map' => $map_id,
			],
			$this->service_fetch_url
		);
	}

	/**
	 * Adds a new layout to the service.
	 *
	 * @since 5.16.0
	 *
	 * @param string $map_id The ID of the map to add the layout to.
	 *
	 * @return string|bool Layout ID on success, false on failure.
	 */
	public function add( string $map_id ) {
		$url = $this->get_add_url( $map_id );

		$args = [
			'method'  => 'POST',
			'headers' => [
				'Authorization' => 'Bearer ' . $this->get_oauth_token(),
				'Content-Type'  => 'application/json',
			],
		];

		$response = wp_remote_request( $url, $args );
		$code     = wp_remote_retrieve_response_code( $response );

		if ( is_wp_error( $response ) || 200 !== $code ) {
			$this->log_error(
				'Failed to Add new layout to the service.',
				[
					'source'   => __METHOD__,
					'code'     => $code,
					'url'      => $url,
					'response' => $response,
				]
			);
			return false;
		}

		$body      = json_decode( wp_remote_retrieve_body( $response ), true );
		$layout_id = Arr::get( $body, [ 'data', 'items', 0, 'id' ] );

		self::invalidate_cache();
		Maps::invalidate_cache();
		return $layout_id;
	}

	/**
	 * Returns the URL to add a new layout.
	 *
	 * @since 5.17.0
	 *
	 * @param string $layout_id The ID of the map to add the layout to.
	 *
	 * @return string The URL for a layout duplication request.
	 */
	public function get_duplicate_url( string $layout_id ): string {
		return add_query_arg(
			[
				'layout' => $layout_id,
			],
			$this->service_fetch_url . '/duplicate'
		);
	}

	/**
	 * Duplicates a layout in the service.
	 *
	 * @since 5.17.0
	 *
	 * @param string $layout_id The ID of the layout to duplicate.
	 *
	 * @return string|bool Layout ID on success, false on failure.
	 */
	public function duplicate_layout( string $layout_id ) {
		$url = $this->get_duplicate_url( $layout_id );

		$args = [
			'method'  => 'POST',
			'headers' => [
				'Authorization' => 'Bearer ' . $this->get_oauth_token(),
				'Content-Type'  => 'application/json',
			],
		];

		$response = wp_remote_request( $url, $args );
		$code     = wp_remote_retrieve_response_code( $response );

		if ( is_wp_error( $response ) || 200 !== $code ) {
			$this->log_error(
				'Failed to duplicate layout in the service.',
				[
					'source'   => __METHOD__,
					'code'     => $code,
					'url'      => $url,
					'response' => $response,
				]
			);
			return false;
		}

		$body      = json_decode( wp_remote_retrieve_body( $response ), true );
		$layout_id = Arr::get( $body, [ 'data', 'items', 0, 'id' ] );

		self::invalidate_cache();
		Maps::invalidate_cache();

		return $layout_id;
	}

	/**
	 * Updates the capacity of all posts for the given layout IDs.
	 *
	 * @since 5.16.0
	 *
	 * @param array<string,int> $updates The layout ID to seats count map.
	 *
	 * @return int|false The number of posts updated, or `false` on failure.
	 */
	public function update_posts_capacity( array $updates ) {
		if ( empty( $updates ) ) {
			return 0;
		}

		$total_updated = 0;

		$ticketable_post_types = Tickets::instance()->post_types();
		/** @var \Tribe__Tickets__Tickets_Handler $tickets_handler */
		$tickets_handler   = tribe( 'tickets.handler' );
		$capacity_meta_key = $tickets_handler->key_capacity;

		/*
		 * The number of posts to update is not known in advance: we cannot run an unbounded query to fetch the IDs to
		 * update.
		 * The Repository provides a query-behind-a-generator API that will allow us to write a readable, not unbounded,
		 * query to the database to fetch the IDs to update.
		 * The list of ticketable post types, though, is not known in advance.
		 * Here we create a Repository class that will be used to query the database for those post type.
		 * Thanks PHP 7+.
		 */
		$repository = new class( $ticketable_post_types ) extends \Tribe__Repository {
			/**
			 * @param string[] $post_types The list of ticketable post types.
			 */
			public function __construct( array $post_types ) {
				$this->default_args['post_type'] = $post_types;
				parent::__construct();
			}
		};

		foreach ( $updates as $layout_id => $seats ) {
			foreach (
				$repository->where( 'meta_equals', Meta::META_KEY_LAYOUT_ID, $layout_id )
							->get_ids( true ) as $post_id
			) {
				$previous_capacity = get_post_meta( $post_id, $capacity_meta_key, true );
				$capacity_delta    = $seats - (int) $previous_capacity;
				$previous_stock    = get_post_meta( $post_id, Global_Stock::GLOBAL_STOCK_LEVEL, true );
				$new_stock         = max( 0, (int) $previous_stock + $capacity_delta );
				update_post_meta( $post_id, $capacity_meta_key, $seats );
				update_post_meta( $post_id, Global_Stock::GLOBAL_STOCK_LEVEL, $new_stock );
				++ $total_updated;
				// The reason we're not running a batch update is to have per-post cache control.
				clean_post_cache( $post_id );
			}

			// Update the Layout seats in the database.
			DB::update(
				Layouts_Table::table_name(),
				[ 'seats' => $seats ],
				[ 'id' => $layout_id ],
				[ '%d' ],
				[ '%s' ]
			);
		}

		// Finally, soft invalidate the layouts' caches.
		self::invalidate_cache( false );

		return $total_updated;
	}
}