<?php
/**
 * BitForgeNet Guest Tickets - CSV exports.
 *
 * Two things are true at once, and the file has to serve both. Someone printing
 * a guest list wants six columns they can read across a page. Someone building a
 * template, or moving the data somewhere else, wants every field the plugin
 * holds, including the ones this event leaves empty — because a missing column
 * cannot tell you whether the event has no dress code or whether the plugin does
 * not record one.
 *
 * So the catalogue of columns is still read from the database schema, and still
 * grows on its own when a later version adds a field. What changed is that the
 * export no longer pours all of it into every file: a short, sensible set is
 * ticked by default, and everything else is one checkbox away. Internal
 * identifiers are not offered at all — a column of primary keys is noise in a
 * spreadsheet, and the thing each one points at is already exported by name.
 *
 * @package BitForgeNet_Guest_Tickets
 * @since   1.5.0
 */

defined( 'ABSPATH' ) || exit;

// This file talks to the plugin's own tables. WordPress has no API for them --
// they are not posts and not meta -- so $wpdb is the only way in. The rows
// change on every booking and every scan, which makes an object cache wrong
// more often than it is right. Table names are built from $wpdb->prefix and
// never from a request, so interpolating one carries nothing to inject; every
// value goes through $wpdb->prepare(), and the few identifiers that cannot
// (ORDER BY columns) are matched against a whitelist first.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter


/**
 * Streams the plugin's CSV exports.
 */
class BFNET_Export {

	/**
	 * Columns that must never leave the server.
	 *
	 * Hashes and fingerprints are stored to make a ticket unforgeable and a
	 * booking traceable; neither belongs in a file that gets emailed around.
	 *
	 * @var array
	 */
	const HIDDEN = array( 'token_hash', 'ip_hash', 'user_agent' );

	/**
	 * The exports on offer.
	 *
	 * @return array
	 */
	public static function sets() {
		return array(
			'orders' => __( 'Bookings — one row per booking', 'bitforgenet-guest-tickets' ),
			'guests' => __( 'Guests — one row per guest', 'bitforgenet-guest-tickets' ),
		);
	}

	/**
	 * Which entities each export carries, and in which order.
	 *
	 * @param string $what Export key.
	 * @return array Prefix => table name.
	 */
	private static function blocks( $what ) {
		if ( 'guests' === $what ) {
			return array(
				'guest'       => 'attendees',
				'ticket'      => 'tickets',
				'order'       => 'orders',
				'event'       => 'events',
				'venue'       => 'venues',
				'organizer'   => 'organizers',
				'ticket_type' => 'ticket_types',
				'discount'    => 'discounts',
			);
		}

		return array(
			'order'     => 'orders',
			'event'     => 'events',
			'venue'     => 'venues',
			'organizer' => 'organizers',
			'discount'  => 'discounts',
		);
	}

	/**
	 * Human names for the groups of columns, for the picker.
	 *
	 * @return array
	 */
	public static function group_labels() {
		return array(
			'guest'       => __( 'Guest', 'bitforgenet-guest-tickets' ),
			'ticket'      => __( 'Ticket', 'bitforgenet-guest-tickets' ),
			'order'       => __( 'Booking', 'bitforgenet-guest-tickets' ),
			'event'       => __( 'Event settings', 'bitforgenet-guest-tickets' ),
			'venue'       => __( 'Venue', 'bitforgenet-guest-tickets' ),
			'organizer'   => __( 'Organizer', 'bitforgenet-guest-tickets' ),
			'ticket_type' => __( 'Ticket type', 'bitforgenet-guest-tickets' ),
			'discount'    => __( 'Discount', 'bitforgenet-guest-tickets' ),
			'computed'    => __( 'Ready-made', 'bitforgenet-guest-tickets' ),
		);
	}

	/**
	 * The readable columns of one plugin table.
	 *
	 * Primary and foreign keys are dropped here rather than merely left
	 * unticked. A column of row numbers helps nobody reading a spreadsheet, and
	 * whatever an id points at — the venue, the ticket type, the event — is
	 * already exported under its own name.
	 *
	 * @param string $name Table name without the prefix.
	 * @return array
	 */
	private static function schema( $name ) {
		$columns = array();

		foreach ( BFNET_Install::columns( BFNET_Install::table( $name ) ) as $column ) {
			if ( in_array( $column, self::HIDDEN, true ) ) {
				continue;
			}
			if ( 'id' === $column || '_id' === substr( $column, -3 ) ) {
				continue;
			}
			$columns[] = $column;
		}

		return $columns;
	}

	/**
	 * The computed columns: the values a human actually wants.
	 *
	 * A name rather than two name fields, dollars rather than cents, a map link
	 * rather than two coordinates.
	 *
	 * @param string $what Export key.
	 * @return array
	 */
	private static function computed( $what ) {
		$common = array(
			'computed_buyer_name',
			'computed_paid',
			'computed_order_subtotal',
			'computed_order_discount',
			'computed_order_total',
			'computed_venue_address',
			'computed_map_url',
			'computed_event_starts',
			'computed_event_doors',
		);

		if ( 'guests' === $what ) {
			return array_merge(
				array( 'computed_guest_name', 'computed_table_number', 'computed_ticket_issued', 'computed_checked_in' ),
				$common
			);
		}

		return array_merge(
			array( 'computed_guest_names', 'computed_table_numbers', 'computed_ticket_serials', 'computed_tickets_issued', 'computed_checked_in_count', 'computed_seated' ),
			$common
		);
	}

	/**
	 * Every column this export can produce, in file order.
	 *
	 * @param string $what Export key.
	 * @return array
	 */
	public static function available( $what ) {
		$all = array();

		foreach ( self::blocks( $what ) as $prefix => $table ) {
			foreach ( self::schema( $table ) as $column ) {
				$all[] = $prefix . '_' . $column;
			}
		}

		return array_merge( $all, self::computed( $what ) );
	}

	/**
	 * The columns ticked before anyone touches anything.
	 *
	 * Ten at most, and chosen for the job the file is usually for: knowing who
	 * is coming, how to reach them, what they are holding and where they sit.
	 *
	 * @param string $what Export key.
	 * @return array
	 */
	public static function defaults( $what ) {
		if ( 'guests' === $what ) {
			$wanted = array(
				'order_reference',
				'guest_last_name',
				'guest_first_name',
				'order_email',
				'order_phone',
				'ticket_serial',
				'guest_table_number',
				'guest_position',
				'order_quantity',
				'computed_paid',
			);
		} else {
			$wanted = array(
				'order_reference',
				'order_last_name',
				'order_first_name',
				'order_email',
				'order_phone',
				'order_quantity',
				'computed_table_numbers',
				'computed_order_total',
				'computed_paid',
				'order_created_at',
			);
		}

		// A default that names a column this installation does not have would
		// quietly produce a narrower file than promised, so it is filtered
		// against what actually exists rather than trusted.
		return array_values( array_intersect( $wanted, self::available( $what ) ) );
	}

	/**
	 * Turn whatever arrived in the request into a usable column list.
	 *
	 * @param string $what   Export key.
	 * @param mixed  $fields Chosen columns, or empty for the default set.
	 * @return array
	 */
	public static function chosen( $what, $fields ) {
		$all = self::available( $what );

		if ( ! is_array( $fields ) ) {
			$fields = ( '' === (string) $fields ) ? array() : explode( ',', (string) $fields );
		}

		$fields = array_filter( array_map( 'sanitize_key', $fields ) );
		$fields = array_values( array_intersect( $all, $fields ) ); // Keeps file order, drops anything invented.

		return $fields ? $fields : self::defaults( $what );
	}

	/**
	 * A readable name for one column, for the picker.
	 *
	 * @param string $key Column key.
	 * @return string
	 */
	public static function label( $key ) {
		$named = array(
			'computed_guest_name'       => __( 'Guest full name', 'bitforgenet-guest-tickets' ),
			'computed_guest_names'      => __( 'All guests on the booking', 'bitforgenet-guest-tickets' ),
			'computed_table_number'     => __( 'Table', 'bitforgenet-guest-tickets' ),
			'computed_table_numbers'    => __( 'Tables', 'bitforgenet-guest-tickets' ),
			'computed_ticket_issued'    => __( 'Ticket issued (yes/no)', 'bitforgenet-guest-tickets' ),
			'computed_ticket_serials'   => __( 'Ticket numbers', 'bitforgenet-guest-tickets' ),
			'computed_tickets_issued'   => __( 'Tickets issued (count)', 'bitforgenet-guest-tickets' ),
			'computed_checked_in'       => __( 'Checked in (yes/no)', 'bitforgenet-guest-tickets' ),
			'computed_checked_in_count' => __( 'Checked in (count)', 'bitforgenet-guest-tickets' ),
			'computed_seated'           => __( 'Seated (yes/no)', 'bitforgenet-guest-tickets' ),
			'computed_buyer_name'       => __( 'Buyer full name', 'bitforgenet-guest-tickets' ),
			'computed_paid'             => __( 'Paid (yes/no)', 'bitforgenet-guest-tickets' ),
			'computed_order_subtotal'   => __( 'Subtotal', 'bitforgenet-guest-tickets' ),
			'computed_order_discount'   => __( 'Discount', 'bitforgenet-guest-tickets' ),
			'computed_order_total'      => __( 'Total', 'bitforgenet-guest-tickets' ),
			'computed_venue_address'    => __( 'Venue address', 'bitforgenet-guest-tickets' ),
			'computed_map_url'          => __( 'Map link', 'bitforgenet-guest-tickets' ),
			'computed_event_starts'     => __( 'Event starts', 'bitforgenet-guest-tickets' ),
			'computed_event_doors'      => __( 'Doors open', 'bitforgenet-guest-tickets' ),
			'order_reference'           => __( 'Booking reference', 'bitforgenet-guest-tickets' ),
			'guest_position'            => __( 'Place in the party', 'bitforgenet-guest-tickets' ),
			'order_quantity'            => __( 'Tickets on the booking', 'bitforgenet-guest-tickets' ),
			'ticket_serial'             => __( 'Ticket number', 'bitforgenet-guest-tickets' ),
		);

		if ( isset( $named[ $key ] ) ) {
			return $named[ $key ];
		}

		$group = self::group_of( $key );
		$rest  = ( 'computed' === $group ) ? substr( $key, 9 ) : substr( $key, strlen( $group ) + 1 );

		return ucfirst( str_replace( '_', ' ', $rest ) );
	}

	/**
	 * Which group a column belongs to.
	 *
	 * @param string $key Column key.
	 * @return string
	 */
	public static function group_of( $key ) {
		// Longest prefix first, so ticket_type_name is not read as a ticket.
		$prefixes = array_keys( self::group_labels() );
		usort(
			$prefixes,
			function ( $a, $b ) {
				return strlen( $b ) - strlen( $a );
			}
		);

		foreach ( $prefixes as $prefix ) {
			if ( 0 === strpos( $key, $prefix . '_' ) ) {
				return $prefix;
			}
		}

		return 'order';
	}

	/**
	 * The available columns arranged for the picker.
	 *
	 * @param string $what Export key.
	 * @return array Group key => array of column keys.
	 */
	public static function catalogue( $what ) {
		$out = array();

		foreach ( self::available( $what ) as $key ) {
			$group = self::group_of( $key );
			if ( ! isset( $out[ $group ] ) ) {
				$out[ $group ] = array();
			}
			$out[ $group ][] = $key;
		}

		return $out;
	}

	/**
	 * Suggested file name.
	 *
	 * @param string      $what  Export key.
	 * @param object|null $event Event row.
	 * @return string
	 */
	public static function filename( $what, $event ) {
		return sprintf(
			'bitforgenet-guest-tickets-%s-%s-%s.csv',
			'guests' === $what ? 'guests' : 'bookings',
			$event ? sanitize_title( $event->title ) : 'all-events',
			gmdate( 'Ymd-Hi' )
		);
	}

	/**
	 * Stream one export to the browser. Exits.
	 *
	 * @param string $what     Export key.
	 * @param int    $event_id Event filter, 0 for every event.
	 * @param mixed  $fields   Chosen columns, or empty for the default set.
	 * @return void
	 */
	public static function stream( $what, $event_id, $fields = array() ) {
		$what   = 'guests' === $what ? 'guests' : 'orders';
		$event  = $event_id ? BFNET_Event::get( (int) $event_id ) : null;
		$fields = self::chosen( $what, $fields );

		nocache_headers();
		header( 'Content-Type: text/csv; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="' . self::filename( $what, $event ) . '"' );

		$out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- a CSV streamed to php://output is not a filesystem write.
		fwrite( $out, "\xEF\xBB\xBF" ); // BOM, so Excel reads UTF-8.

		fputcsv( $out, $fields );

		if ( 'guests' === $what ) {
			self::stream_guests( $out, (int) $event_id, $fields );
		} else {
			self::stream_orders( $out, (int) $event_id, $fields );
		}

		fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		exit;
	}

	/* --------------------------------------------------------------------- */
	/* Rows                                                                   */
	/* --------------------------------------------------------------------- */

	/**
	 * One row per booking.
	 *
	 * @param resource $out      Output handle.
	 * @param int      $event_id Event filter.
	 * @param array    $fields   Chosen columns.
	 * @return void
	 */
	private static function stream_orders( $out, $event_id, array $fields ) {
		global $wpdb;

		$orders = BFNET_Install::table( 'orders' );
		$offset = 0;

		do {
			if ( $event_id ) {
				$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$orders} WHERE event_id = %d ORDER BY id ASC LIMIT 500 OFFSET %d", $event_id, $offset ) ); // phpcs:ignore WordPress.DB.PreparedSQL
			} else {
				$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$orders} ORDER BY id ASC LIMIT 500 OFFSET %d", $offset ) ); // phpcs:ignore WordPress.DB.PreparedSQL
			}

			foreach ( $rows as $order ) {
				$event     = self::event( (int) $order->event_id );
				$venue     = self::venue( $event );
				$organizer = self::organizer( $event );
				$discount  = self::discount( $order );
				$guests    = self::guests_of( (int) $order->id );
				$tickets   = self::tickets_of( (int) $order->id );

				$names   = array();
				$tables  = array();
				$serials = array();
				$in      = 0;

				foreach ( $guests as $guest ) {
					$name = BFNET_Util::full_name( $guest->first_name, $guest->last_name );
					if ( '' !== trim( $name ) ) {
						$names[] = $name;
					}
					$table = isset( $guest->table_number ) ? trim( (string) $guest->table_number ) : '';
					if ( '' !== $table && ! in_array( $table, $tables, true ) ) {
						$tables[] = $table;
					}
				}

				foreach ( $tickets as $ticket ) {
					if ( 'void' === $ticket->status ) {
						continue;
					}
					$serials[] = $ticket->serial;
					if ( (int) $ticket->checked_in ) {
						++$in;
					}
				}

				$computed = array_merge(
					array(
						'computed_guest_names'      => implode( ' | ', $names ),
						'computed_table_numbers'    => implode( ', ', $tables ),
						'computed_ticket_serials'   => implode( ' ', $serials ),
						'computed_tickets_issued'   => count( $serials ),
						'computed_checked_in_count' => $in,
						'computed_seated'           => ( $guests && count( $tables ) ) ? 'yes' : 'no',
					),
					self::common( $order, $event, $venue )
				);

				$map = self::values(
					'orders',
					array(
						'order'     => $order,
						'event'     => $event,
						'venue'     => $venue,
						'organizer' => $organizer,
						'discount'  => $discount,
					),
					$computed
				);

				fputcsv( $out, self::pick( $map, $fields ) );
			}

			$offset += 500;
		} while ( count( $rows ) === 500 );
	}

	/**
	 * One row per guest, issued ticket or not.
	 *
	 * Built from attendees rather than tickets: a guest exists from the moment
	 * somebody books, and a booking waiting for payment is exactly the row an
	 * organiser most wants to see in a spreadsheet.
	 *
	 * @param resource $out      Output handle.
	 * @param int      $event_id Event filter.
	 * @param array    $fields   Chosen columns.
	 * @return void
	 */
	private static function stream_guests( $out, $event_id, array $fields ) {
		global $wpdb;

		$attendees = BFNET_Install::table( 'attendees' );
		$offset    = 0;

		do {
			if ( $event_id ) {
				$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$attendees} WHERE event_id = %d ORDER BY order_id ASC, position ASC LIMIT 500 OFFSET %d", $event_id, $offset ) ); // phpcs:ignore WordPress.DB.PreparedSQL
			} else {
				$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$attendees} ORDER BY order_id ASC, position ASC LIMIT 500 OFFSET %d", $offset ) ); // phpcs:ignore WordPress.DB.PreparedSQL
			}

			foreach ( $rows as $guest ) {
				$order     = self::order( (int) $guest->order_id );
				$event     = self::event( (int) $guest->event_id );
				$venue     = self::venue( $event );
				$organizer = self::organizer( $event );
				$discount  = $order ? self::discount( $order ) : null;
				$ticket    = self::ticket_for( (int) $guest->id );
				$type      = self::ticket_type( (int) $guest->ticket_type_id );

				$computed = array_merge(
					array(
						'computed_guest_name'    => BFNET_Util::full_name( $guest->first_name, $guest->last_name ),
						'computed_table_number'  => isset( $guest->table_number ) ? (string) $guest->table_number : '',
						'computed_ticket_issued' => $ticket ? 'yes' : 'no',
						'computed_checked_in'    => ( $ticket && (int) $ticket->checked_in ) ? 'yes' : 'no',
					),
					self::common( $order, $event, $venue )
				);

				$map = self::values(
					'guests',
					array(
						'guest'       => $guest,
						'ticket'      => $ticket,
						'order'       => $order,
						'event'       => $event,
						'venue'       => $venue,
						'organizer'   => $organizer,
						'ticket_type' => $type,
						'discount'    => $discount,
					),
					$computed
				);

				fputcsv( $out, self::pick( $map, $fields ) );
			}

			$offset += 500;
		} while ( count( $rows ) === 500 );
	}

	/**
	 * The computed columns shared by both exports.
	 *
	 * @param object|null $order Order row.
	 * @param object|null $event Event row.
	 * @param object|null $venue Venue row.
	 * @return array
	 */
	private static function common( $order, $event, $venue ) {
		$currency = $order && $order->currency ? $order->currency : ( $event ? $event->currency : 'CAD' );

		return array(
			'computed_buyer_name'     => $order ? BFNET_Util::full_name( $order->first_name, $order->last_name ) : '',
			'computed_paid'           => ( $order && 'paid' === $order->payment_status ) ? 'yes' : 'no',
			'computed_order_subtotal' => $order ? self::money( $order->subtotal_cents, $currency ) : '',
			'computed_order_discount' => $order ? self::money( $order->discount_cents, $currency ) : '',
			'computed_order_total'    => $order ? self::money( $order->total_cents, $currency ) : '',
			'computed_venue_address'  => $venue ? BFNET_Venue::address_line( $venue ) : '',
			'computed_map_url'        => $venue ? BFNET_Venue::map_url( $venue ) : '',
			'computed_event_starts'   => ( $event && ! empty( $event->starts_at ) ) ? BFNET_Util::event_date( $event, 'starts_at', 'Y-m-d H:i' ) : '',
			'computed_event_doors'    => ( $event && ! empty( $event->doors_open_at ) ) ? BFNET_Util::event_date( $event, 'doors_open_at', 'Y-m-d H:i' ) : '',
		);
	}

	/**
	 * Every value this record can offer, keyed by column.
	 *
	 * @param string $what     Export key.
	 * @param array  $data     Prefix => row object or null.
	 * @param array  $computed Computed values, keyed by column.
	 * @return array
	 */
	private static function values( $what, array $data, array $computed ) {
		$map = array();

		foreach ( self::blocks( $what ) as $prefix => $table ) {
			$source = isset( $data[ $prefix ] ) ? $data[ $prefix ] : null;

			foreach ( self::schema( $table ) as $column ) {
				$value = ( $source && isset( $source->$column ) ) ? $source->$column : '';
				$map[ $prefix . '_' . $column ] = ( null === $value ) ? '' : (string) $value;
			}
		}

		foreach ( $computed as $key => $value ) {
			$map[ $key ] = ( null === $value ) ? '' : (string) $value;
		}

		return $map;
	}

	/**
	 * Reduce a record to the chosen columns, in file order.
	 *
	 * @param array $map    Column => value.
	 * @param array $fields Chosen columns.
	 * @return array
	 */
	private static function pick( array $map, array $fields ) {
		$row = array();

		foreach ( $fields as $key ) {
			$row[] = isset( $map[ $key ] ) ? $map[ $key ] : '';
		}

		return $row;
	}

	/**
	 * Plain decimal money, without a currency symbol a spreadsheet would choke on.
	 *
	 * @param int    $cents    Amount in cents.
	 * @param string $currency Currency code, unused but kept for clarity.
	 * @return string
	 */
	private static function money( $cents, $currency ) {
		unset( $currency );
		return number_format( (int) $cents / 100, 2, '.', '' );
	}

	/* --------------------------------------------------------------------- */
	/* Lookups, each cached so a 500-row page costs a handful of queries       */
	/* --------------------------------------------------------------------- */

	/**
	 * Cached event.
	 *
	 * @param int $id Event id.
	 * @return object|null
	 */
	private static function event( $id ) {
		static $cache = array();
		if ( ! $id ) {
			return null;
		}
		if ( ! array_key_exists( $id, $cache ) ) {
			$cache[ $id ] = BFNET_Event::get( $id );
		}
		return $cache[ $id ];
	}

	/**
	 * Cached order.
	 *
	 * @param int $id Order id.
	 * @return object|null
	 */
	private static function order( $id ) {
		static $cache = array();
		if ( ! $id ) {
			return null;
		}
		if ( ! array_key_exists( $id, $cache ) ) {
			$cache[ $id ] = BFNET_Order::get( $id );
		}
		return $cache[ $id ];
	}

	/**
	 * Cached venue for an event.
	 *
	 * @param object|null $event Event row.
	 * @return object|null
	 */
	private static function venue( $event ) {
		static $cache = array();
		$id = $event ? (int) $event->venue_id : 0;
		if ( ! $id ) {
			return null;
		}
		if ( ! array_key_exists( $id, $cache ) ) {
			$cache[ $id ] = BFNET_Venue::get( $id );
		}
		return $cache[ $id ];
	}

	/**
	 * Cached organizer for an event.
	 *
	 * @param object|null $event Event row.
	 * @return object|null
	 */
	private static function organizer( $event ) {
		static $cache = array();
		$id = $event ? (int) $event->organizer_id : 0;
		if ( ! $id ) {
			return null;
		}
		if ( ! array_key_exists( $id, $cache ) ) {
			$cache[ $id ] = class_exists( 'BFNET_Organizer' ) ? BFNET_Organizer::get( $id ) : null;
		}
		return $cache[ $id ];
	}

	/**
	 * Cached ticket type.
	 *
	 * @param int $id Ticket type id.
	 * @return object|null
	 */
	private static function ticket_type( $id ) {
		static $cache = array();
		if ( ! $id ) {
			return null;
		}
		if ( ! array_key_exists( $id, $cache ) ) {
			global $wpdb;
			$table        = BFNET_Install::table( 'ticket_types' );
			$cache[ $id ] = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL
		}
		return $cache[ $id ];
	}

	/**
	 * The discount an order used, if the code still exists.
	 *
	 * @param object $order Order row.
	 * @return object|null
	 */
	private static function discount( $order ) {
		static $cache = array();

		$code = isset( $order->discount_code ) ? trim( (string) $order->discount_code ) : '';
		if ( '' === $code ) {
			return null;
		}

		$key = (int) $order->event_id . '|' . strtoupper( $code );
		if ( ! array_key_exists( $key, $cache ) ) {
			global $wpdb;
			$table         = BFNET_Install::table( 'discounts' );
			$cache[ $key ] = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE event_id = %d AND code = %s", (int) $order->event_id, strtoupper( $code ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL
		}

		return $cache[ $key ];
	}

	/**
	 * Guests on a booking.
	 *
	 * @param int $order_id Order id.
	 * @return array
	 */
	private static function guests_of( $order_id ) {
		global $wpdb;
		$table = BFNET_Install::table( 'attendees' );
		return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE order_id = %d ORDER BY position ASC", $order_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * Tickets on a booking.
	 *
	 * @param int $order_id Order id.
	 * @return array
	 */
	private static function tickets_of( $order_id ) {
		global $wpdb;
		$table = BFNET_Install::table( 'tickets' );
		return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE order_id = %d ORDER BY id ASC", $order_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * The live ticket for one guest.
	 *
	 * @param int $attendee_id Attendee id.
	 * @return object|null
	 */
	private static function ticket_for( $attendee_id ) {
		global $wpdb;
		$table = BFNET_Install::table( 'tickets' );
		return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE attendee_id = %d AND status <> 'void' ORDER BY id DESC LIMIT 1", $attendee_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL
	}
}
