<?php
/**
 * Seats Helper.
 *
 * Per event, you can specify the number of seats and the seats' configuration.
 * An example is "A:1-20,B1-15,C1-23". Always make sure the configuration matches the number of seats.
 *
 * Storage in the seats table is done with the ticket qrcode as a key (with a 'Q' in front). The value
 * is a sequence number which runs from 1 to the maximum number of seats.
 *
 * The real seat assignment is done when the ticket is printed. Based on the sequence number, the real seat is
 * calculated from the configuration.
 *
 * @since 3.0.0
 */

declare( strict_types=1 );
namespace FeData\FastEvents\Helpers;

use FeData\FastEvents\Logging\LoggerFactory;
use FeData\FastEvents\Utilities\JsonUtil;
use stdClass;
use WP_Error;

/**
 * A class with seats functions
 */
final class SeatsHelper {

	/**
	 * Array of seats_configuration per event_id.
	 *
	 * @since 1.0
	 * @var array
	 */
	private array $seats = array();

	/**
	 * Array of JSON seat maps per event_id.
	 *
	 * @since 1.0
	 * @var array
	 */
	private array $json = array();

	/**
	 * SeatsHelper constructor.
	 *
	 * @since 3.0.0
	 *
	 * @param LoggerFactory $logger Logger.
	 */
	public function __construct( private readonly LoggerFactory $logger ) {
	}


	/**
	 * Create a row entry in the seat table for an event
	 *
	 * @since 1.0
	 *
	 * @param int    $event_id     Event identifier.
	 * @param int    $num_seats    Number of seats.
	 * @param string $seat_format  Print configuration (e.g. "row %s, seat %d").
	 * @param string $seat_config  Seat configuration (eg, "A:1-20,B1-15,C1-23").
	 * @param int    $linked_event Use the linked event for seat assignment.
	 *
	 * @return WP_Error|bool
	 */
	public function create_entry_for_event( int $event_id, int $num_seats, string $seat_format, string $seat_config, int $linked_event ): WP_Error|bool {
		global $wpdb;

		$result = $wpdb->insert(
			$wpdb->prefix . 'fe_event_seats',
			array(
				'event_id'     => $event_id,
				'num_seats'    => $num_seats,
				'seat_format'  => $seat_format,
				'seat_config'  => $seat_config,
				'linked_event' => $linked_event,
			)
		);
		if ( false === $result ) {
			$this->logger->error(
				'Failed to create seats row for event: ' . $wpdb->last_error,
				array(
					'event_id'     => $event_id,
					'type'         => 'Seats',
					'num_seats'    => $num_seats,
					'seat_format'  => $seat_format,
					'seat_config'  => $seat_config,
					'linked_event' => $linked_event,
				)
			);
			return new WP_Error( 'failed', esc_html__( 'Creating seats row for event failed', 'fast-events' ) . ': ' . $wpdb->last_error, array( 'status' => 409 ) );
		}

		return true;
	}


	/**
	 * Update a row entry in the seat table for an event
	 *
	 * @since 1.0
	 *
	 * @param int    $event_id     Event identifier.
	 * @param int    $num_seats    Number of seats.
	 * @param string $seat_format  Print configuration (e.g. "row %s, seat %d").
	 * @param string $seat_config  Seat configuration (eg, "A:1-20,B1-15,C1-23").
	 * @param int    $linked_event Use the linked event for seat assignment.
	 *
	 * @return WP_Error|bool
	 */
	public function update_entry_for_event( int $event_id, int $num_seats, string $seat_format, string $seat_config, int $linked_event ): WP_Error|bool {
		global $wpdb;

		$result = $wpdb->update(
			$wpdb->prefix . 'fe_event_seats',
			array(
				'num_seats'    => $num_seats,
				'seat_format'  => $seat_format,
				'seat_config'  => $seat_config,
				'linked_event' => $linked_event,
			),
			array( 'event_id' => $event_id )
		);
		if ( false === $result ) {
			$this->logger->error(
				'Update seats row for event failed: ' . $wpdb->last_error,
				array(
					'event_id'     => $event_id,
					'type'         => 'Seats',
					'num_seats'    => $num_seats,
					'seat_format'  => $seat_format,
					'seat_config'  => $seat_config,
					'linked_event' => $linked_event,
				)
			);
			return new WP_Error( 'failed', esc_html__( 'Update seats row for event failed', 'fast-events' ) . ': ' . $wpdb->last_error, array( 'status' => 409 ) );
		}

		return true;
	}


	/**
	 * Clear all seats for an event
	 *
	 * @since 1.0
	 *
	 * @param int $event_id Event identifier.
	 *
	 * @return WP_Error|bool
	 */
	public function clear_all_seats_for_event( int $event_id ): WP_Error|bool {
		global $wpdb;

		$result = $wpdb->update(
			$wpdb->prefix . 'fe_event_seats',
			array(
				'last_seat_id' => 0,
				'open_seats'   => 0,
				'seat_map'     => '',
			),
			array( 'event_id' => $event_id )
		);
		if ( false === $result ) {
			$this->logger->error(
				'Failed to clear all seats: ' . $wpdb->last_error,
				array(
					'event_id' => $event_id,
					'type'     => 'Seats',
				)
			);
			return new WP_Error( 'failed', esc_html__( 'Failed to clear all seats', 'fast-events' ) . ': ' . $wpdb->last_error, array( 'status' => 409 ) );
		}

		return true;
	}


	/**
	 * Get a row entry in the seat table for an event
	 *
	 * @since 1.0
	 *
	 * @param int $event_id Event identifier.
	 *
	 * @return WP_Error|stdClass
	 */
	public function get_entry_for_event( int $event_id ): WP_Error|stdClass {
		global $wpdb;

		$seat = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT num_seats, seat_format, seat_config, linked_event FROM {$wpdb->prefix}fe_event_seats WHERE event_id = %d",
				$event_id
			)
		);
		if ( ! $seat ) {
			$this->logger->error(
				'Seats row for event not found: ' . $wpdb->last_error,
				array(
					'event_id' => $event_id,
					'type'     => 'Seats',
				)
			);
			return new WP_Error( 'not_found', esc_html__( 'Seats row for event not found', 'fast-events' ) . ': ' . $wpdb->last_error, array( 'status' => 404 ) );
		}

		return $seat;
	}


	/**
	 * Delete a row entry in the seat table for an event
	 *
	 * @since 1.0
	 *
	 * @param int $event_id Event identifier.
	 *
	 * @return WP_Error|bool
	 */
	public function delete_entry_for_event( int $event_id ): WP_Error|bool {
		global $wpdb;

		$resp = $wpdb->delete( $wpdb->prefix . 'fe_event_seats', array( 'event_id' => $event_id ) );
		if ( false === $resp ) {
			$this->logger->error(
				'Failed to delete seats row: ' . $wpdb->last_error,
				array(
					'event_id' => $event_id,
					'type'     => 'Seats',
				)
			);
			return new WP_Error( 'not_found', esc_html__( 'Failed to delete seats row', 'fast-events' ) . ': ' . $wpdb->last_error, array( 'status' => 400 ) );
		}

		return true;
	}


	/**
	 * Validate a seats configuration
	 *
	 * @since 1.0
	 *
	 * @param int    $num_seats   Number of seats.
	 * @param string $seat_format Print configuration (e.g. "row %s, seat %d").
	 * @param string $seat_config Seat configuration (eg, "A:1-20,B1-15,C1-23").
	 *
	 * @return WP_Error|bool
	 */
	public function validate_config( int $num_seats, string $seat_format, string $seat_config ): WP_Error|bool {

		// Zero seats are not allowed.
		if ( 0 === $num_seats ) {
			return new WP_Error( 'not_allowed', esc_html__( 'Invalid number of seats', 'fast-events' ), array( 'status' => 400 ) );
		}

		// Empty format and config is not allowed.
		if ( empty( $seat_format ) || empty( $seat_config ) ) {
			return new WP_Error( 'not_allowed', esc_html__( 'Seats format or config is empty', 'fast-events' ), array( 'status' => 404 ) );
		}

		// Does the config match the pattern?
		$match = preg_match( '/^(\w+:\d+-\d+)(,\w+:\d+-\d+)*$/', $seat_config );
		if ( false === $match || 0 === $match ) {
			return new WP_Error( 'invalid_config', esc_html__( 'Invalid seat configuration', 'fast-events' ), array( 'status' => 404 ) );
		}

		// Does the number of seats match the configuration?
		$total = 0;
		$part  = explode( ',', $seat_config );
		foreach ( $part as $row ) {
			$el      = explode( ':', $row );
			$numbers = explode( '-', $el[1] );
			$range   = (int) $numbers[1] - (int) $numbers[0] + 1;
			$total  += $range;
		}
		if ( $num_seats !== $total ) {
			return new WP_Error( 'no_match', esc_html__( 'The number of seats does not match the configuration', 'fast-events' ), array( 'status' => 404 ) );
		}

		return true;
	}




	/**
	 * Get the seat row of the linked one
	 *
	 * @since 1.0
	 * @since 2.1.0 Optional 'FOR UPDATE'
	 *
	 * @param int  $event_id  Event identifier.
	 * @param bool $is_update Is this for an update.
	 *
	 * @return null|stdClass
	 */
	private function get_seats_or_linked_seats( int $event_id, bool $is_update = true ): ?object {
		global $wpdb;

		// Look up the seats row.
		$lock  = $is_update ? 'FOR UPDATE' : '';
		$seats = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}fe_event_seats WHERE event_id = %d {$lock}", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$event_id
			)
		);
		if ( ! $seats ) {
			return null;
		}

		// Is there a linked seats row?
		$linked_event = (int) $seats->linked_event;
		if ( 0 !== $linked_event ) {
			$seats = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT * FROM {$wpdb->prefix}fe_event_seats WHERE event_id = %d {$lock}", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					$linked_event
				)
			);
			if ( ! $seats ) {
				return null;
			}
		}

		return $seats;
	}


	/**
	 * Get the seat based on event_id and qrcode
	 *
	 * @since 1.0
	 * @since 2.1.0 Call get_seats without an update lock.
	 *
	 * @param int    $event_id Event identifier.
	 * @param string $qrcode   The unique qrcode of the eticket.
	 *
	 * @return string
	 */
	public function get_seat( int $event_id, string $qrcode ): string {

		// Is the seats' entry already in the cache?
		if ( ! isset( $this->seats[ $event_id ] ) ) {
			$this->seats[ $event_id ] = $this->get_seats_or_linked_seats( $event_id, false );
			if ( is_null( $this->seats[ $event_id ] ) ) {
				return '';
			}
			$this->json[ $event_id ] = json_decode( $this->seats[ $event_id ]->seat_map );
		}

		// Get the seat id.
		$lookup_code = 'Q' . $qrcode;
		if ( isset( $this->json[ $event_id ]->{$lookup_code} ) ) {
			$seat_id = $this->json[ $event_id ]->{$lookup_code};
		} else {
			return '';
		}

		// Map the seat_id to a real seat.
		$number = 0;
		$part   = explode( ',', $this->seats[ $event_id ]->seat_config );
		foreach ( $part as $row ) {
			$el      = explode( ':', $row );
			$row_id  = $el[0];
			$numbers = explode( '-', $el[1] );
			$range   = (int) $numbers[1] - (int) $numbers[0] + 1;
			if ( ( $number + $range ) >= $seat_id ) {
				$tmp         = $seat_id - $number;
				$row_seat_id = (int) $numbers[0] + $tmp - 1;
				return sprintf( $this->seats[ $event_id ]->seat_format, $row_id, $row_seat_id );
			}
			$number += $range;
		}

		return '';
	}


	/**
	 * Assigns seat_ids to an array of qrcodes from possible different events
	 *
	 * @since 1.0
	 * @since 2.3.0 Use fast_events_json_encode.
	 *
	 * @param array $event_qrcodes Array of event_ids and qrcodes.
	 */
	public function assign_seat_ids( array $event_qrcodes ): void {
		global $wpdb;

		// Start the transaction.
		$wpdb->query( 'START TRANSACTION' );

		foreach ( $event_qrcodes as $row ) {

			// Is the seats' entry already in the cache?
			if ( ! isset( $this->seats[ $row->event_id ] ) ) {
				$this->seats[ $row->event_id ] = $this->get_seats_or_linked_seats( (int) $row->event_id );
				if ( is_null( $this->seats[ $row->event_id ] ) ) {
					$wpdb->query( 'ROLLBACK' );
					return;
				}
				$this->json[ $row->event_id ] = json_decode( $this->seats[ $row->event_id ]->seat_map, true );
			}

			// Are there still seats available?
			$code = 'Q' . $row->qrcode;
			if ( (int) $this->seats[ $row->event_id ]->last_seat_id < (int) $this->seats[ $row->event_id ]->num_seats ) {
				$new_id = (int) $this->seats[ $row->event_id ]->last_seat_id + 1;

				// Assign the seat.
				$this->json[ $row->event_id ][ $code ] = $new_id;
				++$this->seats[ $row->event_id ]->last_seat_id;
			} elseif ( 1 === (int) $this->seats[ $row->event_id ]->open_seats ) {

				// We end here if all seats are filled, but there are open seats due to order/ticket removals.
				$flip = array_flip( $this->json[ $row->event_id ] );
				for ( $i = 1; $i <= (int) $this->seats[ $row->event_id ]->num_seats; $i++ ) {
					if ( ! isset( $flip[ $i ] ) ) {
						$this->json[ $row->event_id ][ $code ] = $i;
						break;
					}
				}

				// Check if this was the last open seat.
				if ( ( $i - 1 ) === (int) $this->seats[ $row->event_id ]->num_seats ) {
					$this->seats[ $row->event_id ]->open_seats = 0;
				}
			}
		}

		// Update the seats rows.
		foreach ( $this->seats as $key => $value ) {
			$wpdb->update(
				$wpdb->prefix . 'fe_event_seats',
				array(
					'last_seat_id' => $value->last_seat_id,
					'open_seats'   => $value->open_seats,
					'seat_map'     => JsonUtil::fast_events_json_encode( $this->json[ $key ] ),
				),
				array( 'event_id' => $this->seats[ $key ]->event_id )
			);
		}

		$wpdb->query( 'COMMIT' );
	}


	/**
	 * Delete seat_ids from an array of qrcodes
	 *
	 * @since 1.0
	 * @since 2.3.0 Use fast_events_json_encode.
	 *
	 * @param int   $event_id Event identifier.
	 * @param array $qrcodes  Array of event_ids and qrcodes.
	 */
	public function delete_seat_ids( int $event_id, array $qrcodes ): void {
		global $wpdb;

		// Start the transaction.
		$wpdb->query( 'START TRANSACTION' );

		// Look up the seats row.
		$seats = $this->get_seats_or_linked_seats( $event_id );
		if ( is_null( $seats ) ) {
			$wpdb->query( 'ROLLBACK' );
			return;
		}

		// Remove the seats.
		$json = json_decode( $seats->seat_map );
		foreach ( $qrcodes as $qrcode ) {
			$code = 'Q' . $qrcode;
			unset( $json->{$code} );
		}

		// Update the table.
		$wpdb->update(
			$wpdb->prefix . 'fe_event_seats',
			array(
				'open_seats' => 1,
				'seat_map'   => JsonUtil::fast_events_json_encode( $json ),
			),
			array( 'event_id' => $seats->event_id )
		);

		$wpdb->query( 'COMMIT' );
	}


	/**
	 * Assign real seats to an array of tickets
	 *
	 * @since 1.0
	 *
	 * @param int   $event_id Event identifier.
	 * @param array $tickets  Array of tickets.
	 */
	public function get_bulk_seats( int $event_id, array &$tickets ): void {

		// Get the seats' configuration.
		$seats = $this->get_seats_or_linked_seats( $event_id );
		if ( is_null( $seats ) ) {
			return;
		}

		// Prepare.
		$json = json_decode( $seats->seat_map );
		$part = explode( ',', $seats->seat_config );
		$qid  = esc_html__( 'Qrcode', 'fast-events' );
		$ttp  = esc_html__( 'Tickettype', 'fast-events' );

		// Map the seat_id to a real seat.
		foreach ( $tickets as $key => $ticket ) {
			if ( empty( $ticket->{$qid} ) ) {
				continue;
			}

			// Get the seat id.
			$lookup_code = 'Q' . $ticket->{$qid};
			if ( isset( $json->{$lookup_code} ) ) {
				$seat_id = $json->{$lookup_code};
			} else {
				continue;
			}

			$number = 0;
			foreach ( $part as $row ) {
				$el      = explode( ':', $row );
				$row_id  = $el[0];
				$numbers = explode( '-', $el[1] );
				$range   = (int) $numbers[1] - (int) $numbers[0] + 1;
				if ( ( $number + $range ) >= $seat_id ) {
					$tmp         = $seat_id - $number;
					$row_seat_id = (int) $numbers[0] + $tmp - 1;

					$tickets[ $key ]->{$ttp} .= sprintf( $seats->seat_format, $row_id, $row_seat_id );
					break;
				}
				$number += $range;
			}
		}
	}
}
