<?php
/**
 * BitForgeNet Guest Tickets - Ticket model: issuance, QR tokens, verification and check-in.
 *
 * Every attendee on an order gets exactly one ticket, with its own serial number
 * and its own single-use QR token. The raw token never touches the database; only
 * an HMAC-SHA256 digest keyed with AUTH_SALT is stored, so a database dump alone
 * cannot be turned into working tickets.
 *
 * @package BitForgeNet_Guest_Tickets
 * @since   1.0.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


/**
 * Data access and access-control logic for tickets.
 */
class BFNET_Ticket {

	/**
	 * Table name.
	 *
	 * @return string
	 */
	public static function table() {
		return BFNET_Install::table( 'tickets' );
	}

	/**
	 * Fetch one ticket.
	 *
	 * @param int $id Ticket id.
	 * @return object|null
	 */
	public static function get( $id ) {
		global $wpdb;
		$table = self::table();
		return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", (int) $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * Fetch one ticket by its public code.
	 *
	 * @param string $code Ticket code.
	 * @return object|null
	 */
	public static function get_by_code( $code ) {
		global $wpdb;
		$table = self::table();
		return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE code = %s", (string) $code ) ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * Fetch a ticket from a raw QR token.
	 *
	 * @param string $token Raw token from the QR code.
	 * @return object|null
	 */
	public static function get_by_token( $token ) {
		global $wpdb;

		$token = (string) $token;

		// A ticket typed on the door keypad, or read by a barcode wand. Tickets
		// store only a digest of their QR token, so a printed code can never be
		// turned back into one; the door resolves it to a row and says so here.
		if ( 0 === strpos( $token, 'id:' ) ) {
			$table = self::table();
			return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", (int) substr( $token, 3 ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL
		}

		$token = preg_replace( '/[^a-f0-9]/i', '', $token );
		if ( strlen( $token ) < 32 ) {
			return null;
		}

		$table = self::table();
		$hash  = BFNET_Security::hash_token( $token );

		return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE token_hash = %s", $hash ) ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * Query tickets.
	 *
	 * @param array $args Query arguments.
	 * @return array
	 */
	public static function query( $args = array() ) {
		global $wpdb;

		$args = wp_parse_args(
			$args,
			array(
				'event_id'   => 0,
				'order_id'   => 0,
				'status'     => '',
				'checked_in' => null,
				'search'     => '',
				'orderby'    => 'id',
				'order'      => 'DESC',
				'limit'      => 50,
				'offset'     => 0,
			)
		);

		$tickets   = self::table();
		$attendees = BFNET_Install::table( 'attendees' );
		$where     = array( '1=1' );
		$params    = array();

		if ( $args['event_id'] ) {
			$where[]  = 't.event_id = %d';
			$params[] = (int) $args['event_id'];
		}
		if ( $args['order_id'] ) {
			$where[]  = 't.order_id = %d';
			$params[] = (int) $args['order_id'];
		}
		if ( $args['status'] ) {
			$where[]  = 't.status = %s';
			$params[] = $args['status'];
		}
		if ( null !== $args['checked_in'] ) {
			$where[]  = 't.checked_in = %d';
			$params[] = $args['checked_in'] ? 1 : 0;
		}
		if ( '' !== $args['search'] ) {
			$like     = '%' . $wpdb->esc_like( $args['search'] ) . '%';
			$where[]  = '(t.code LIKE %s OR t.serial LIKE %s OR a.first_name LIKE %s OR a.last_name LIKE %s)';
			$params   = array_merge( $params, array( $like, $like, $like, $like ) );
		}

		$allowed = array( 'id', 'code', 'checked_in_at', 'issued_at' );
		$orderby = in_array( $args['orderby'], $allowed, true ) ? $args['orderby'] : 'id';
		$order   = ( 'ASC' === strtoupper( $args['order'] ) ) ? 'ASC' : 'DESC';

		$params[] = max( 1, (int) $args['limit'] );
		$params[] = max( 0, (int) $args['offset'] );

		// Only ask for the table where the column exists. A schema that has not
		// caught up should cost the table column, never the whole list.
		$has_table = in_array( 'table_number', BFNET_Install::columns( $attendees ), true );
		$table_col = $has_table ? 'a.table_number' : "'' AS table_number";

		$sql = "SELECT t.*, a.first_name, a.last_name, a.email AS attendee_email, a.category, {$table_col}
			FROM {$tickets} t
			LEFT JOIN {$attendees} a ON a.id = t.attendee_id
			WHERE " . implode( ' AND ', $where ) . "
			ORDER BY t.{$orderby} {$order} LIMIT %d OFFSET %d";

		return $wpdb->get_results( $wpdb->prepare( $sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/* --------------------------------------------------------------------- */
	/* Issuance                                                              */
	/* --------------------------------------------------------------------- */

	/**
	 * Issue one ticket per attendee for a paid order.
	 *
	 * Returns the raw tokens keyed by ticket id — this is the only moment the raw
	 * token exists, so the caller must render the QR codes immediately.
	 *
	 * @param int $order_id Order id.
	 * @return array|WP_Error Map of ticket_id => ['ticket' => object, 'token' => string].
	 */
	public static function issue_for_order( $order_id ) {
		global $wpdb;

		$order = BFNET_Order::get( $order_id );
		if ( ! $order ) {
			return new WP_Error( 'bfnet_no_order', __( 'Order not found.', 'bitforgenet-guest-tickets' ) );
		}

		$event = BFNET_Event::get( $order->event_id );
		if ( ! $event ) {
			return new WP_Error( 'bfnet_no_event', __( 'Event not found.', 'bitforgenet-guest-tickets' ) );
		}

		$attendees = BFNET_Order::attendees( $order_id );
		if ( empty( $attendees ) ) {
			return new WP_Error( 'bfnet_no_attendees', __( 'This order has no attendees.', 'bitforgenet-guest-tickets' ) );
		}

		$table    = self::table();
		$issued   = array();
		$now      = current_time( 'mysql' );
		$template = self::resolve_template_id( $event );

		foreach ( $attendees as $attendee ) {
			// Never issue twice for the same attendee.
			$existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE attendee_id = %d AND status != 'void'", (int) $attendee->id ) ); // phpcs:ignore WordPress.DB.PreparedSQL
			if ( $existing ) {
				continue;
			}

			$token = BFNET_Security::random_token( 24 );
			$hash  = BFNET_Security::hash_token( $token );

			$serial = self::unique_number( (int) $event->id );
			$code   = $serial;

			$ok = $wpdb->insert(
				$table,
				array(
					'uuid'           => BFNET_Util::uuid(),
					'code'           => $code,
					'serial'         => $serial,
					'token_hash'     => $hash,
					'event_id'       => (int) $event->id,
					'order_id'       => (int) $order_id,
					'attendee_id'    => (int) $attendee->id,
					'ticket_type_id' => (int) $attendee->ticket_type_id,
					'template_id'    => $template,
					'status'         => 'issued',
					'issued_at'      => $now,
					'created_at'     => $now,
				)
			);

			if ( ! $ok ) {
				continue;
			}

			$ticket_id            = (int) $wpdb->insert_id;
			$issued[ $ticket_id ] = array(
				'ticket'   => self::get( $ticket_id ),
				'attendee' => $attendee,
				'token'    => $token,
			);
		}

		if ( ! empty( $issued ) ) {
			$wpdb->update(
				BFNET_Order::table(),
				array( 'tickets_issued' => 1, 'updated_at' => $now ),
				array( 'id' => (int) $order_id ),
				array( '%d', '%s' ),
				array( '%d' )
			);
			BFNET_Log::audit( 'order', $order_id, 'tickets_issued', array( 'count' => count( $issued ) ) );
		}

		return $issued;
	}

	/**
	 * Regenerate the QR token for a single ticket (used when a ticket is re-sent
	 * after a suspected leak). Invalidates the previous QR immediately.
	 *
	 * @param int $ticket_id Ticket id.
	 * @return string|WP_Error New raw token.
	 */
	public static function rotate_token( $ticket_id ) {
		global $wpdb;

		$ticket = self::get( $ticket_id );
		if ( ! $ticket ) {
			return new WP_Error( 'bfnet_no_ticket', __( 'Ticket not found.', 'bitforgenet-guest-tickets' ) );
		}

		$token = BFNET_Security::random_token( 24 );

		// Regenerating is when a design change is meant to take effect. The old
		// artwork was being kept because the template was fixed at issue time,
		// so making a new design the default appeared to do nothing.
		$event    = BFNET_Event::get( (int) $ticket->event_id );
		$template = $event ? self::resolve_template_id( $event ) : (int) $ticket->template_id;

		$wpdb->update(
			self::table(),
			array(
				'token_hash'       => BFNET_Security::hash_token( $token ),
				'template_id'      => $template,
				'pdf_file'         => '',
				'pdf_generated_at' => null,
			),
			array( 'id' => (int) $ticket_id ),
			array( '%s', '%d', '%s', '%s' ),
			array( '%d' )
		);

		BFNET_Log::audit( 'ticket', $ticket_id, 'token_rotated', array( 'code' => $ticket->code ), 'warning' );

		return $token;
	}

	/**
	 * Void every ticket on an order.
	 *
	 * @param int    $order_id Order id.
	 * @param string $reason   Reason.
	 * @return int Number of tickets voided.
	 */
	public static function void_for_order( $order_id, $reason = '' ) {
		global $wpdb;

		$table = self::table();

		$count = (int) $wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET status = 'void', voided_at = %s, void_reason = %s WHERE order_id = %d AND status != 'void'",
				current_time( 'mysql' ),
				substr( sanitize_text_field( $reason ), 0, 190 ),
				(int) $order_id
			)
		); // phpcs:ignore WordPress.DB.PreparedSQL

		if ( $count ) {
			BFNET_Log::audit( 'order', $order_id, 'tickets_voided', array( 'count' => $count, 'reason' => $reason ), 'warning' );
		}

		return $count;
	}

	/**
	 * Void a single ticket.
	 *
	 * @param int    $ticket_id Ticket id.
	 * @param string $reason    Reason.
	 * @return bool
	 */
	public static function void( $ticket_id, $reason = '' ) {
		global $wpdb;

		$wpdb->update(
			self::table(),
			array( 'status' => 'void', 'voided_at' => current_time( 'mysql' ), 'void_reason' => substr( sanitize_text_field( $reason ), 0, 190 ) ),
			array( 'id' => (int) $ticket_id ),
			array( '%s', '%s', '%s' ),
			array( '%d' )
		);

		BFNET_Log::audit( 'ticket', $ticket_id, 'ticket_voided', array( 'reason' => $reason ), 'warning' );

		return true;
	}

	/**
	 * Delete every ticket (and its PDF) for an order.
	 *
	 * @param int $order_id Order id.
	 * @return void
	 */
	public static function delete_for_order( $order_id ) {
		global $wpdb;

		$tickets = BFNET_Order::tickets( $order_id );
		foreach ( $tickets as $ticket ) {
			self::delete_pdf( $ticket );
			$wpdb->delete( BFNET_Install::table( 'scans' ), array( 'ticket_id' => (int) $ticket->id ), array( '%d' ) );
		}

		$wpdb->delete( self::table(), array( 'order_id' => (int) $order_id ), array( '%d' ) );
	}

	/**
	 * Remove a ticket's cached PDF from disk.
	 *
	 * @param object $ticket Ticket row.
	 * @return void
	 */
	public static function delete_pdf( $ticket ) {
		if ( empty( $ticket->pdf_file ) ) {
			return;
		}
		$storage = BFNET_Install::storage();
		$path    = $storage['dir'] . '/tickets/' . basename( $ticket->pdf_file );
		if ( is_file( $path ) ) {
			wp_delete_file( $path );
		}
	}

	/* --------------------------------------------------------------------- */
	/* Verification and check-in                                             */
	/* --------------------------------------------------------------------- */

	/**
	 * Validate a scanned token without changing any state.
	 *
	 * @param string $token    Raw token.
	 * @param int    $event_id Expected event id, or 0 to accept any event.
	 * @return array Result payload with 'result', 'message' and context.
	 */
	public static function verify( $token, $event_id = 0 ) {
		$ticket = self::get_by_token( $token );

		if ( ! $ticket ) {
			return array(
				'result'  => 'invalid',
				'ok'      => false,
				'message' => __( 'Unknown ticket. This QR code is not in the database.', 'bitforgenet-guest-tickets' ),
			);
		}

		$order    = BFNET_Order::get( $ticket->order_id );
		$event    = BFNET_Event::get( $ticket->event_id );
		$attendee = self::attendee( $ticket );

		$context = array(
			'ticket'   => $ticket,
			'order'    => $order,
			'event'    => $event,
			'attendee' => $attendee,
		);

		if ( $event_id && (int) $ticket->event_id !== (int) $event_id ) {
			return array_merge(
				$context,
				array(
					'result'  => 'wrong_event',
					'ok'      => false,
					'message' => __( 'This ticket belongs to a different event.', 'bitforgenet-guest-tickets' ),
				)
			);
		}

		if ( 'void' === $ticket->status ) {
			return array_merge(
				$context,
				array(
					'result'  => 'void',
					'ok'      => false,
					/* translators: %s: reason the ticket was voided. */
					'message' => sprintf( __( 'This ticket has been cancelled. %s', 'bitforgenet-guest-tickets' ), $ticket->void_reason ),
				)
			);
		}

		if ( ! $order || 'paid' !== $order->payment_status ) {
			return array_merge(
				$context,
				array(
					'result'  => 'not_paid',
					'ok'      => false,
					'message' => __( 'The payment for this ticket is not confirmed.', 'bitforgenet-guest-tickets' ),
				)
			);
		}

		if ( $ticket->checked_in && ! (int) BFNET_Settings::get( 'allow_multi_scan', 0 ) ) {
			return array_merge(
				$context,
				array(
					'result'  => 'already_used',
					'ok'      => false,
					/* translators: %s: date and time of the first check-in. */
					'message' => sprintf( __( 'Already checked in at %s.', 'bitforgenet-guest-tickets' ), BFNET_Util::date( $ticket->checked_in_at ) ),
				)
			);
		}

		if ( $event && ! self::within_checkin_window( $event ) ) {
			return array_merge(
				$context,
				array(
					'result'  => 'outside_window',
					'ok'      => false,
					'message' => __( 'Outside the check-in window for this event.', 'bitforgenet-guest-tickets' ),
				)
			);
		}

		return array_merge(
			$context,
			array(
				'result'  => 'valid',
				'ok'      => true,
				'message' => __( 'Valid ticket. Welcome!', 'bitforgenet-guest-tickets' ),
			)
		);
	}

	/**
	 * Verify and, when valid, record the check-in.
	 *
	 * @param string $token    Raw token.
	 * @param int    $event_id Expected event id.
	 * @param string $gate     Gate label.
	 * @return array Verification payload.
	 */
	public static function check_in( $token, $event_id = 0, $gate = '' ) {
		global $wpdb;

		$outcome = self::verify( $token, $event_id );
		$ticket  = isset( $outcome['ticket'] ) ? $outcome['ticket'] : null;
		$now     = current_time( 'mysql' );

		if ( $ticket ) {
			$update = array(
				'scan_count'   => (int) $ticket->scan_count + 1,
				'last_scan_at' => $now,
			);

			if ( ! empty( $outcome['ok'] ) ) {
				$update['checked_in']    = 1;
				$update['checked_in_at'] = $ticket->checked_in ? $ticket->checked_in_at : $now;
				$update['checked_in_by'] = get_current_user_id();
				$update['status']        = 'used';
				$update['gate']          = substr( sanitize_text_field( $gate ), 0, 60 );
			}

			$wpdb->update( self::table(), $update, array( 'id' => (int) $ticket->id ), null, array( '%d' ) );

			if ( ! empty( $outcome['ok'] ) ) {
				BFNET_Order::refresh_event_counters( (int) $ticket->event_id );
				$outcome['ticket'] = self::get( (int) $ticket->id );
			}
		}

		self::log_scan(
			$ticket ? (int) $ticket->id : 0,
			$ticket ? (int) $ticket->event_id : (int) $event_id,
			$outcome['result'],
			$gate,
			$token
		);

		return $outcome;
	}

	/**
	 * Admit a guest the rules would have turned away.
	 *
	 * Doors need discretion: someone arrives before the doors officially open, or
	 * a ticket was scanned twice because the first scan was not seen. The agent
	 * decides, and the decision is written down with the reason it was needed.
	 *
	 * @param int    $ticket_id Ticket id.
	 * @param string $gate      Gate name.
	 * @param string $reason    The outcome that was overridden.
	 * @return bool
	 */
	public static function force_check_in( $ticket_id, $gate = '', $reason = '' ) {
		global $wpdb;

		$ticket = self::get( $ticket_id );
		if ( ! $ticket ) {
			return false;
		}

		$now = current_time( 'mysql' );

		$wpdb->update(
			self::table(),
			array(
				'checked_in'    => 1,
				'checked_in_at' => $ticket->checked_in ? $ticket->checked_in_at : $now,
				'checked_in_by' => get_current_user_id(),
				'status'        => 'used',
				'gate'          => substr( sanitize_text_field( $gate ), 0, 60 ),
				'scan_count'    => (int) $ticket->scan_count + 1,
				'last_scan_at'  => $now,
			),
			array( 'id' => (int) $ticket->id ),
			null,
			array( '%d' )
		);

		BFNET_Order::refresh_event_counters( (int) $ticket->event_id );
		self::log_scan( (int) $ticket->id, (int) $ticket->event_id, 'override:' . $reason, $gate );
		BFNET_Log::security( 'door_override', array( 'ticket' => (int) $ticket->id, 'reason' => $reason ) );

		return true;
	}

	/**
	 * Reverse a check-in (mis-scan at the door).
	 *
	 * @param int $ticket_id Ticket id.
	 * @return bool
	 */
	public static function undo_check_in( $ticket_id ) {
		global $wpdb;

		$ticket = self::get( $ticket_id );
		if ( ! $ticket ) {
			return false;
		}

		$wpdb->update(
			self::table(),
			array( 'checked_in' => 0, 'checked_in_at' => null, 'checked_in_by' => 0, 'status' => 'issued' ),
			array( 'id' => (int) $ticket_id ),
			array( '%d', '%s', '%d', '%s' ),
			array( '%d' )
		);

		BFNET_Order::refresh_event_counters( (int) $ticket->event_id );
		BFNET_Log::audit( 'ticket', $ticket_id, 'checkin_reverted', array( 'code' => $ticket->code ), 'warning' );

		return true;
	}

	/**
	 * Write a row into the scan log.
	 *
	 * @param int    $ticket_id Ticket id (0 when unknown).
	 * @param int    $event_id  Event id.
	 * @param string $result    Result slug.
	 * @param string $gate      Gate label.
	 * @param string $token     Raw token (only a fragment is stored).
	 * @return void
	 */
	public static function log_scan( $ticket_id, $event_id, $result, $gate = '', $token = '' ) {
		global $wpdb;

		$wpdb->insert(
			BFNET_Install::table( 'scans' ),
			array(
				'ticket_id'      => (int) $ticket_id,
				'event_id'       => (int) $event_id,
				'user_id'        => get_current_user_id(),
				'result'         => substr( sanitize_key( $result ), 0, 40 ),
				'gate'           => substr( sanitize_text_field( $gate ), 0, 60 ),
				'token_fragment' => substr( preg_replace( '/[^a-f0-9]/i', '', (string) $token ), 0, 8 ),
				'ip_hash'        => BFNET_Security::ip_hash(),
				'user_agent'     => BFNET_Security::user_agent(),
				'scanned_at'     => current_time( 'mysql' ),
			)
		);
	}

	/**
	 * Whether the current time falls inside an event's check-in window.
	 *
	 * @param object $event Event row.
	 * @return bool
	 */
	public static function within_checkin_window( $event ) {
		$before = (int) BFNET_Settings::get( 'checkin_window_before', 240 );
		$after  = (int) BFNET_Settings::get( 'checkin_window_after', 360 );

		if ( $before <= 0 && $after <= 0 ) {
			return true;
		}

		$start = BFNET_Util::event_ts( $event, 'starts_at' );
		if ( null === $start ) {
			return true;
		}

		$end = BFNET_Util::event_ts( $event, 'ends_at' );
		$end = ( null === $end ) ? $start : $end;
		$now = time();

		return ( $now >= $start - ( $before * MINUTE_IN_SECONDS ) ) && ( $now <= $end + ( $after * MINUTE_IN_SECONDS ) );
	}

	/* --------------------------------------------------------------------- */
	/* Helpers                                                               */
	/* --------------------------------------------------------------------- */

	/**
	 * Attendee row for a ticket.
	 *
	 * @param object $ticket Ticket row.
	 * @return object|null
	 */
	public static function attendee( $ticket ) {
		global $wpdb;
		$table = BFNET_Install::table( 'attendees' );
		return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", (int) $ticket->attendee_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * Public verification URL embedded in the QR code.
	 *
	 * @param string $token Raw token.
	 * @return string
	 */
	public static function verify_url( $token ) {
		$endpoint = sanitize_title( (string) BFNET_Settings::get( 'qr_endpoint', 'bfnet-verify' ) );

		// Without pretty permalinks the rewrite rule can never match, and the QR
		// would lead to a 404 on the one night it matters. Fall back to the plain
		// query-string form, which the router accepts just as happily.
		if ( ! get_option( 'permalink_structure' ) ) {
			return add_query_arg( 'bfnet_token', rawurlencode( $token ), home_url( '/' ) );
		}

		return home_url( '/' . $endpoint . '/' . rawurlencode( $token ) . '/' );
	}

	/**
	 * Signed, expiring URL that lets a buyer download a ticket PDF.
	 *
	 * @param object $ticket Ticket row.
	 * @param int    $ttl    Lifetime in seconds.
	 * @return string
	 */
	public static function download_url( $ticket, $ttl = DAY_IN_SECONDS ) {
		$expires = time() + (int) $ttl;
		$payload = 'ticket:' . $ticket->uuid;

		return add_query_arg(
			array(
				'bfnet_download' => $ticket->uuid,
				'expires'      => $expires,
				'signature'    => BFNET_Security::sign( $payload, $expires ),
			),
			home_url( '/' )
		);
	}

	/**
	 * Next sequence number for an event's tickets.
	 *
	 * @param int $event_id Event id.
	 * @return int
	 */
	private static function next_sequence( $event_id ) {
		global $wpdb;
		$table = self::table();
		$count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE event_id = %d", (int) $event_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL
		return $count + 1;
	}

	/**
	 * Guarantee the ticket code is unique.
	 *
	 * @param string $serial Base serial.
	 * @return string
	 */
	/**
	 * Characters a ticket number may contain.
	 *
	 * O, I, L, 0 and 1 are left out so that a number read off a printed ticket in
	 * a dim doorway and typed into a phone can never be ambiguous.
	 */
	const NUMBER_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';

	/**
	 * How many characters a ticket number carries.
	 */
	const NUMBER_LENGTH = 6;

	/**
	 * Draw a ticket number that is not part of a series.
	 *
	 * Sequential numbers leak the size of the guest list and invite a forgery: if
	 * ticket 000004 exists, so does 000005. Six characters from a 31-symbol
	 * alphabet is close to 900 million possibilities — short enough to type at a
	 * door, unguessable enough to be worth checking. The number is unique across
	 * the whole database, so two events running the same night can never collide.
	 *
	 * @param int $event_id Event the ticket belongs to.
	 * @return string
	 */
	private static function unique_number( $event_id ) {
		global $wpdb;

		$table    = self::table();
		$alphabet = self::NUMBER_ALPHABET;
		$last     = strlen( $alphabet ) - 1;

		for ( $attempt = 0; $attempt < 30; $attempt++ ) {
			$candidate = '';
			for ( $i = 0; $i < self::NUMBER_LENGTH; $i++ ) {
				$candidate .= $alphabet[ wp_rand( 0, $last ) ];
			}

			$taken = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE code = %s OR serial = %s", $candidate, $candidate ) ); // phpcs:ignore WordPress.DB.PreparedSQL
			if ( ! $taken ) {
				return $candidate;
			}
		}

		// Thirty collisions in a row means the alphabet is exhausted, which it
		// never is; lengthen rather than fail.
		return strtoupper( substr( md5( uniqid( (string) $event_id, true ) ), 0, 10 ) );
	}

	/**
	 * Normalise a ticket number the way both storage and the door expect it.
	 *
	 * @param string $value Raw input.
	 * @return string
	 */
	public static function clean_number( $value ) {
		return strtoupper( preg_replace( '/[^A-Za-z0-9]/', '', (string) $value ) );
	}

	/**
	 * Resolve which template an event's tickets should use.
	 *
	 * @param object $event Event row.
	 * @return int
	 */
	private static function resolve_template_id( $event ) {
		if ( ! empty( $event->template_id ) && BFNET_Template::get( (int) $event->template_id ) ) {
			return (int) $event->template_id;
		}
		$default = BFNET_Template::get_default();
		return $default ? (int) $default->id : 0;
	}
}
