<?php
/**
 * BitForgeNet Guest Tickets - The door station.
 *
 * A check-in terminal that lives outside wp-admin on purpose.
 *
 * The admin scanner had a structural weakness: a WordPress admin page loads dozens
 * of other scripts and stylesheets, any one of which can throw an error that stops
 * our own script from ever running, or apply a rule that collapses the camera
 * preview. From the agent's side both look identical — a black rectangle. The door
 * station renders its own page from nothing: no theme, no admin, no third-party
 * asset, with the scanner code inlined so no cache, CDN or minifier sits between
 * the file we ship and the code that runs.
 *
 * It also stops depending on things that hosts routinely switch off. The route is
 * resolved from REQUEST_URI rather than a rewrite rule, so a database that never
 * received a permalink flush cannot produce a 404. Scans are posted to this same
 * route rather than to the REST API, which security plugins commonly disable.
 * Agents authenticate with a per-event PIN instead of a WordPress account.
 *
 * @package BitForgeNet_Guest_Tickets
 * @since   1.2.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


/**
 * Front controller for the door station and the ticket verification link.
 */
class BFNET_Door {

	/**
	 * Session cookie name.
	 */
	const COOKIE = 'bfnet_door_session';

	/**
	 * Option holding the per-event door PINs.
	 */
	const PIN_OPTION = 'bfnet_door_pins';

	/**
	 * How long a door session lasts once the PIN is accepted.
	 */
	const SESSION_TTL = 18 * HOUR_IN_SECONDS;

	/**
	 * Hook up.
	 */
	public function __construct() {
		// Priority 0: we answer before the theme, the query, or anything else runs.
		add_action( 'init', array( $this, 'route' ), 0 );
	}

	/* --------------------------------------------------------------------- */
	/* Routing                                                               */
	/* --------------------------------------------------------------------- */

	/**
	 * The station endpoint slug.
	 *
	 * @return string
	 */
	public static function endpoint() {
		$slug = sanitize_title( (string) BFNET_Settings::get( 'door_endpoint', 'bfnet-door' ) );
		return $slug ? $slug : 'bfnet-door';
	}

	/**
	 * Public URL of the door station.
	 *
	 * @param array $args Extra query arguments.
	 * @return string
	 */
	public static function url( $args = array() ) {
		// Without pretty permalinks there are no rewrite rules in .htaccess, so a
		// path-style URL would be refused by the web server before PHP ever runs.
		if ( ! get_option( 'permalink_structure' ) ) {
			$url = add_query_arg( 'bfnet_door', '1', home_url( '/' ) );
			return $args ? add_query_arg( $args, $url ) : $url;
		}

		$url = home_url( '/' . self::endpoint() . '/' );
		return $args ? add_query_arg( $args, $url ) : $url;
	}

	/**
	 * URL of a station sub-action, in whichever form this site can serve.
	 *
	 * @param string $sub Sub-action, e.g. "out".
	 * @return string
	 */
	public static function url_sub( $sub ) {
		if ( ! get_option( 'permalink_structure' ) ) {
			return add_query_arg( 'bfnet_door', rawurlencode( $sub ), home_url( '/' ) );
		}
		return trailingslashit( self::url() ) . rawurlencode( $sub );
	}

	/**
	 * The path segments of the current request, relative to the WordPress root.
	 *
	 * Derived from REQUEST_URI rather than from the rewrite rules, because a rule
	 * that was never flushed into the database is exactly the failure this class
	 * exists to survive.
	 *
	 * @return array
	 */
	private function segments() {
		$uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
		$uri = (string) wp_parse_url( $uri, PHP_URL_PATH );

		$home = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );
		if ( $home && '/' !== $home && 0 === strpos( $uri, $home ) ) {
			$uri = substr( $uri, strlen( $home ) - 1 );
		}

		$uri = trim( rawurldecode( $uri ), '/' );

		return '' === $uri ? array() : explode( '/', $uri );
	}

	/**
	 * Answer the request when it belongs to us.
	 *
	 * @return void
	 */
	public function route() {
		// The plain query-string form, used when the site has no pretty permalinks.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! empty( $_GET['bfnet_token'] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$this->handle_verify( sanitize_text_field( wp_unslash( $_GET['bfnet_token'] ) ) );
			return;
		}

		// Pricing a discount code, answered from the plugin's own front controller.
		// admin-ajax replies with a bare "0" whenever anything upstream declines
		// to dispatch the action, and a bare 0 is indistinguishable from a real
		// refusal — which is how a working code came to report itself broken.
		// This route answers before WordPress has decided anything.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( isset( $_REQUEST['bfnet_quote'] ) ) {
			$this->handle_quote();
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( isset( $_GET['bfnet_door'] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$sub = sanitize_key( wp_unslash( $_GET['bfnet_door'] ) );
			$this->handle_station( '1' === $sub ? '' : $sub );
			return;
		}

		$segments = $this->segments();
		if ( ! $segments ) {
			return;
		}

		$first = strtolower( $segments[0] );

		if ( self::endpoint() === $first ) {
			$this->handle_station( isset( $segments[1] ) ? $segments[1] : '' );
			return;
		}

		// The link printed inside every ticket QR code.
		$verify = sanitize_title( (string) BFNET_Settings::get( 'qr_endpoint', 'bfnet-verify' ) );
		if ( $verify && $verify === $first && isset( $segments[1] ) && '' !== $segments[1] ) {
			$this->handle_verify( $segments[1] );
		}
	}

	/**
	 * URL that prices a discount code.
	 *
	 * @return string
	 */
	public static function quote_url() {
		return add_query_arg( 'bfnet_quote', '1', home_url( '/' ) );
	}

	/**
	 * Price a discount code against a basket, and answer with JSON.
	 *
	 * @return void
	 */
	private function handle_quote() {
		nocache_headers();
		header( 'Content-Type: application/json; charset=utf-8' );

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$event_id = isset( $_REQUEST['event'] ) ? (int) $_REQUEST['event'] : 0;
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$code = isset( $_REQUEST['code'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['code'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$subtotal = isset( $_REQUEST['subtotal'] ) ? max( 0, (int) $_REQUEST['subtotal'] ) : 0;
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$quantity = isset( $_REQUEST['quantity'] ) ? max( 1, (int) $_REQUEST['quantity'] ) : 1;

		if ( ! BFNET_Security::rate_limit( 'promo', 90, HOUR_IN_SECONDS ) ) {
			echo wp_json_encode( array( 'ok' => false, 'message' => __( 'Too many attempts. Try again shortly.', 'bitforgenet-guest-tickets' ) ) );
			exit;
		}

		$event = BFNET_Event::get( $event_id );

		if ( ! $event ) {
			echo wp_json_encode( array( 'ok' => false, 'message' => __( 'This event is no longer available.', 'bitforgenet-guest-tickets' ) ) );
			exit;
		}

		$applied = BFNET_Offers::apply( $event_id, $code, $subtotal, $quantity );

		if ( is_wp_error( $applied ) ) {
			echo wp_json_encode( array( 'ok' => false, 'message' => $applied->get_error_message() ) );
			exit;
		}

		if ( ! is_array( $applied ) ) {
			echo wp_json_encode( array( 'ok' => false, 'message' => __( 'This event does not take discount codes.', 'bitforgenet-guest-tickets' ) ) );
			exit;
		}

		echo wp_json_encode(
			array(
				'ok'      => true,
				'label'   => $applied['label'],
				'amount'  => (int) $applied['amount_cents'],
				'message' => sprintf(
					/* translators: %s: money amount taken off. */
					__( '%s off applied.', 'bitforgenet-guest-tickets' ),
					BFNET_Util::money( (int) $applied['amount_cents'], $event->currency )
				),
			)
		);
		exit;
	}

	/* --------------------------------------------------------------------- */
	/* PIN and session                                                       */
	/* --------------------------------------------------------------------- */

	/**
	 * All stored PIN records.
	 *
	 * @return array
	 */
	private static function pins() {
		$pins = get_option( self::PIN_OPTION, array() );
		return is_array( $pins ) ? $pins : array();
	}

	/**
	 * The PIN record for an event, if one has been issued.
	 *
	 * @param int $event_id Event id.
	 * @return array|null
	 */
	public static function pin_record( $event_id ) {
		$pins = self::pins();
		return isset( $pins[ (int) $event_id ] ) ? $pins[ (int) $event_id ] : null;
	}

	/**
	 * Issue (or re-issue) the door PIN for an event.
	 *
	 * Re-issuing bumps the version, which silently invalidates every session
	 * already handed out — the way to lock out a phone that walked off.
	 *
	 * @param int $event_id Event id.
	 * @return string The new PIN, in clear, shown once to the manager.
	 */
	public static function issue_pin( $event_id ) {
		$event_id = (int) $event_id;
		$pins     = self::pins();
		$previous = isset( $pins[ $event_id ] ) ? (int) $pins[ $event_id ]['version'] : 0;

		$pin = self::mint_code();

		$pins[ $event_id ] = array(
			'hash'    => self::hash_pin( $pin ),
			'version' => $previous + 1,
			'issued'  => time(),
			'by'      => get_current_user_id(),
		);

		update_option( self::PIN_OPTION, $pins, false );
		BFNET_Log::security( 'door_pin_issued', array( 'event' => $event_id ) );

		return $pin;
	}

	/**
	 * Withdraw the PIN for an event, closing every open station.
	 *
	 * @param int $event_id Event id.
	 * @return void
	 */
	public static function revoke_pin( $event_id ) {
		$pins = self::pins();
		unset( $pins[ (int) $event_id ] );
		update_option( self::PIN_OPTION, $pins, false );
		BFNET_Log::security( 'door_pin_revoked', array( 'event' => (int) $event_id ) );
	}

	/**
	 * Characters a door code may contain.
	 *
	 * O, 0, I, 1 and L are left out: an agent reading a code off a phone screen
	 * in the dark should never have to guess which one it is.
	 */
	const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';

	/**
	 * How many characters a door code carries.
	 */
	const CODE_LENGTH = 8;

	/**
	 * Draw a fresh door code.
	 *
	 * Eight characters from a 31-symbol alphabet is a little over 39 bits —
	 * some 850 billion possibilities. With attempts rate-limited per address,
	 * guessing one is not a strategy.
	 *
	 * @return string
	 */
	private static function mint_code() {
		$alphabet = self::ALPHABET;
		$last     = strlen( $alphabet ) - 1;
		$code     = '';

		for ( $i = 0; $i < self::CODE_LENGTH; $i++ ) {
			$code .= $alphabet[ wp_rand( 0, $last ) ];
		}

		return $code;
	}

	/**
	 * Normalise whatever the agent typed.
	 *
	 * @param string $pin Raw input.
	 * @return string
	 */
	private static function clean_code( $pin ) {
		return strtoupper( preg_replace( '/[^A-Za-z0-9]/', '', (string) $pin ) );
	}

	/**
	 * Keyed digest of a PIN.
	 *
	 * @param string $pin Clear PIN.
	 * @return string
	 */
	private static function hash_pin( $pin ) {
		return hash_hmac( 'sha256', 'door:' . self::clean_code( $pin ), defined( 'AUTH_SALT' ) ? AUTH_SALT : 'bfnet' );
	}

	/**
	 * Find the event a PIN belongs to.
	 *
	 * @param string $pin Clear PIN.
	 * @return int Event id, or 0.
	 */
	private static function event_for_pin( $pin ) {
		$pin = self::clean_code( $pin );
		if ( self::CODE_LENGTH !== strlen( $pin ) ) {
			return 0;
		}

		$candidate = self::hash_pin( $pin );

		foreach ( self::pins() as $event_id => $record ) {
			if ( ! empty( $record['hash'] ) && hash_equals( (string) $record['hash'], $candidate ) ) {
				return (int) $event_id;
			}
		}

		return 0;
	}

	/**
	 * Mint a signed session value.
	 *
	 * @param int $event_id Event id.
	 * @return string
	 */
	private static function mint_session( $event_id ) {
		$record  = self::pin_record( $event_id );
		$version = $record ? (int) $record['version'] : 1;
		$expires = time() + self::SESSION_TTL;
		$payload = (int) $event_id . '.' . $version . '.' . $expires;

		return $payload . '.' . hash_hmac( 'sha256', 'session:' . $payload, defined( 'AUTH_SALT' ) ? AUTH_SALT : 'bfnet' );
	}

	/**
	 * Validate the session cookie.
	 *
	 * @return int Event id the station is bound to, or 0.
	 */
	public static function session_event() {
		// A signed-in manager needs no PIN.
		if ( is_user_logged_in() && BFNET_Security::can( 'bfnet_scan_tickets' ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$asked = isset( $_GET['event'] ) ? (int) $_GET['event'] : 0;
			if ( $asked ) {
				return $asked;
			}
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
		$raw = isset( $_COOKIE[ self::COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE ] ) ) : '';
		if ( ! $raw ) {
			return self::manager_fallback();
		}

		$parts = explode( '.', $raw );
		if ( 4 !== count( $parts ) ) {
			return self::manager_fallback();
		}

		list( $event_id, $version, $expires, $signature ) = $parts;
		$payload = (int) $event_id . '.' . (int) $version . '.' . (int) $expires;
		$expect  = hash_hmac( 'sha256', 'session:' . $payload, defined( 'AUTH_SALT' ) ? AUTH_SALT : 'bfnet' );

		if ( ! hash_equals( $expect, (string) $signature ) ) {
			return self::manager_fallback();
		}

		if ( (int) $expires < time() ) {
			return self::manager_fallback();
		}

		$record = self::pin_record( (int) $event_id );
		if ( ! $record || (int) $record['version'] !== (int) $version ) {
			return self::manager_fallback();
		}

		return (int) $event_id;
	}

	/**
	 * A signed-in manager may open any event's station without a PIN.
	 *
	 * @return int
	 */
	private static function manager_fallback() {
		if ( ! is_user_logged_in() || ! BFNET_Security::can( 'bfnet_scan_tickets' ) ) {
			return 0;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$asked = isset( $_GET['event'] ) ? (int) $_GET['event'] : 0;
		if ( $asked ) {
			return $asked;
		}

		$next = BFNET_Event::query( array( 'statuses' => array( 'open', 'closed', 'sold_out', 'published' ), 'orderby' => 'starts_at', 'order' => 'ASC', 'limit' => 1 ) );
		return $next ? (int) $next[0]->id : 0;
	}

	/* --------------------------------------------------------------------- */
	/* Request handling                                                      */
	/* --------------------------------------------------------------------- */

	/**
	 * Serve the station.
	 *
	 * @param string $sub Sub-path after the endpoint.
	 * @return void
	 */
	private function handle_station( $sub ) {
		nocache_headers();
		header( 'Permissions-Policy: camera=(self), microphone=()' );
		header( 'Feature-Policy: camera \'self\'' );
		header( 'X-Frame-Options: SAMEORIGIN' );

		$sub    = strtolower( sanitize_key( $sub ) );
		$action = '';
		if ( isset( $_POST['bfnet_action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
			$action = sanitize_key( wp_unslash( $_POST['bfnet_action'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
		}

		if ( 'scan' === $action ) {
			$this->handle_scan();
		}

		if ( 'pin' === $action ) {
			$this->handle_pin();
		}

		if ( 'out' === $sub ) {
			$this->clear_cookie();
			wp_safe_redirect( self::url() );
			exit;
		}

		$event_id = self::session_event();

		if ( ! $event_id ) {
			$this->render_pin_screen( '' );
			exit;
		}

		$event = BFNET_Event::get( $event_id );
		if ( ! $event ) {
			$this->clear_cookie();
			$this->render_pin_screen( __( 'That event no longer exists. Ask for a new code.', 'bitforgenet-guest-tickets' ) );
			exit;
		}

		$this->render_station( $event );
		exit;
	}

	/**
	 * Accept or refuse a PIN.
	 *
	 * @return void
	 */
	private function handle_pin() {
		$ip = BFNET_Security::ip_hash();

		if ( ! BFNET_Security::rate_limit( 'door_pin', 12, 15 * MINUTE_IN_SECONDS, 'ip:' . $ip ) ) {
			BFNET_Log::security( 'door_pin_rate_limited', array() );
			$this->render_pin_screen( __( 'Too many attempts. Wait a few minutes and try again.', 'bitforgenet-guest-tickets' ) );
			exit;
		}

		// phpcs:ignore WordPress.Security.NonceVerification
		$pin      = isset( $_POST['pin'] ) ? sanitize_text_field( wp_unslash( $_POST['pin'] ) ) : '';
		$event_id = self::event_for_pin( $pin );

		if ( ! $event_id ) {
			BFNET_Log::security( 'door_pin_failed', array() );
			$this->render_pin_screen( __( 'That code is not recognised.', 'bitforgenet-guest-tickets' ) );
			exit;
		}

		$this->set_cookie( self::mint_session( $event_id ) );
		BFNET_Log::security( 'door_session_opened', array( 'event' => $event_id ) );

		wp_safe_redirect( self::url() );
		exit;
	}

	/**
	 * Record a scan and answer with JSON.
	 *
	 * @return void
	 */
	private function handle_scan() {
		$event_id = self::session_event();

		header( 'Content-Type: application/json; charset=utf-8' );

		if ( ! $event_id ) {
			echo wp_json_encode(
				array(
					'state'   => 'session',
					'title'   => __( 'Station closed', 'bitforgenet-guest-tickets' ),
					'message' => __( 'This station is no longer signed in. Reload the page and enter the door code again.', 'bitforgenet-guest-tickets' ),
				)
			);
			exit;
		}

		if ( ! BFNET_Security::rate_limit( 'door_scan', 1200, HOUR_IN_SECONDS, 'event:' . $event_id ) ) {
			echo wp_json_encode(
				array(
					'state'   => 'error',
					'title'   => __( 'Slow down', 'bitforgenet-guest-tickets' ),
					'message' => __( 'Too many scans in a short time.', 'bitforgenet-guest-tickets' ),
				)
			);
			exit;
		}

		// phpcs:ignore WordPress.Security.NonceVerification
		$input = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification
		$gate = isset( $_POST['gate'] ) ? sanitize_text_field( wp_unslash( $_POST['gate'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification
		$peek = ! empty( $_POST['peek'] );
		// phpcs:ignore WordPress.Security.NonceVerification
		$override = ! empty( $_POST['override'] );

		echo wp_json_encode( self::scan( $input, $event_id, $gate, $peek, $override ) );
		exit;
	}

	/**
	 * Resolve whatever the door read, then verify or check in.
	 *
	 * @param string $input    QR content, raw token, ticket code or serial tail.
	 * @param int    $event_id Event the station is bound to.
	 * @param string $gate     Gate name.
	 * @param bool   $peek     Look without consuming.
	 * @return array
	 */
	public static function scan( $input, $event_id, $gate = '', $peek = false, $override = false ) {
		$token = self::resolve( $input, $event_id );

		if ( '' === $token ) {
			return array(
				'state'   => 'bad',
				'title'   => __( 'Not a ticket', 'bitforgenet-guest-tickets' ),
				'message' => __( 'That code does not belong to any ticket for this event.', 'bitforgenet-guest-tickets' ),
			);
		}

		$outcome = $peek
			? BFNET_Ticket::verify( $token, $event_id )
			: BFNET_Ticket::check_in( $token, $event_id, $gate );

		// A real door needs a way to say yes anyway. A guest who arrives before
		// the doors officially open, or whose ticket was scanned twice by
		// mistake, is a judgement call — so the station asks rather than simply
		// refusing, and the override is written to the log with the reason.
		$result = isset( $outcome['result'] ) ? $outcome['result'] : '';

		if ( $override && ! $peek && in_array( $result, self::overridable(), true ) && ! empty( $outcome['ticket'] ) ) {
			BFNET_Ticket::force_check_in( (int) $outcome['ticket']->id, $gate, $result );
			$outcome = BFNET_Ticket::verify( $token, $event_id );
			$view    = self::present( $outcome, $event_id );

			$view['state']   = 'ok';
			$view['title']   = __( 'Let in by the door', 'bitforgenet-guest-tickets' );
			$view['message'] = __( 'Admitted on the agent’s decision. The override is recorded.', 'bitforgenet-guest-tickets' );

			return $view;
		}

		$view = self::present( $outcome, $event_id );

		if ( ! $peek && in_array( $result, self::overridable(), true ) && ! empty( $outcome['ticket'] ) ) {
			$view['can_override'] = true;
			$view['token']        = $token;
		}

		return $view;
	}

	/**
	 * Outcomes an agent is allowed to wave through.
	 *
	 * Never a forgery, never an unpaid ticket, never someone else's event:
	 * those are not judgement calls.
	 *
	 * @return array
	 */
	private static function overridable() {
		return array( 'outside_window', 'already_used' );
	}

	/**
	 * Turn anything the door read into a raw QR token.
	 *
	 * The station accepts far more than a QR code: a pasted verification link, a
	 * ticket code typed on the keypad, a serial number from a barcode wand, or
	 * just the last digits of a serial when the print is worn.
	 *
	 * @param string $input Raw input.
	 * @return string Raw token, or an empty string.
	 */
	public static function resolve( $input, $event_id = 0 ) {
		$input = trim( (string) $input );
		if ( '' === $input ) {
			return '';
		}

		// Our own marker for a ticket found by its printed number. The override
		// button hands this straight back to us, and refusing to recognise it is
		// what turned "let them in anyway" into a red refusal.
		if ( preg_match( '/^id:\d+$/', $input ) ) {
			return $input;
		}

		// A verification link: the token is the last path segment, or a query arg.
		if ( false !== stripos( $input, '://' ) || false !== strpos( $input, '/' ) || false !== strpos( $input, '?' ) ) {
			$query = (string) wp_parse_url( $input, PHP_URL_QUERY );
			if ( $query ) {
				parse_str( $query, $args );
				if ( ! empty( $args['bfnet_token'] ) ) {
					$input = (string) $args['bfnet_token'];
				}
			}

			if ( false !== strpos( $input, '/' ) ) {
				$parts = array_values( array_filter( explode( '/', (string) wp_parse_url( $input, PHP_URL_PATH ) ) ) );
				if ( $parts ) {
					$input = (string) end( $parts );
				}
			}
		}

		// A raw token is hexadecimal and long.
		$hex = preg_replace( '/[^a-f0-9]/i', '', $input );
		if ( strlen( $hex ) >= 32 && BFNET_Ticket::get_by_token( $hex ) ) {
			return $hex;
		}

		// Otherwise it is something a human typed or a wand read.
		return self::token_from_printed( $input, $event_id );
	}

	/**
	 * Find the token behind a printed ticket code, serial, or serial tail.
	 *
	 * @param string $input Printed value.
	 * @return string Raw token, or an empty string.
	 */
	private static function token_from_printed( $input, $event_id = 0 ) {
		global $wpdb;

		$clean = BFNET_Ticket::clean_number( $input );
		if ( '' === $clean ) {
			return '';
		}

		$table = BFNET_Ticket::table();

		// Scoped to the event the station is signed in to. Two parties running on
		// the same night must never be able to reach into each other's guest list,
		// and it lets the agent type the number alone with nothing in front of it.
		if ( $event_id ) {
			$ticket = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL
				$wpdb->prepare(
					"SELECT * FROM {$table} WHERE event_id = %d AND (code = %s OR serial = %s) LIMIT 1",
					(int) $event_id,
					$clean,
					$clean
				)
			);
		} else {
			$ticket = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE code = %s OR serial = %s LIMIT 1", $clean, $clean ) ); // phpcs:ignore WordPress.DB.PreparedSQL
		}

		// Tickets issued before numbers became random still carry a prefix, so a
		// tail is accepted too — but only when exactly one ticket on this event
		// ends that way, never when it is ambiguous.
		if ( ! $ticket && strlen( $clean ) >= 4 ) {
			$like = '%' . $wpdb->esc_like( $clean );

			$matches = $event_id
				? $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE event_id = %d AND (serial LIKE %s OR code LIKE %s) LIMIT 2", (int) $event_id, $like, $like ) ) // phpcs:ignore WordPress.DB.PreparedSQL
				: $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE serial LIKE %s LIMIT 2", $like ) ); // phpcs:ignore WordPress.DB.PreparedSQL

			if ( 1 === count( $matches ) ) {
				$ticket = $matches[0];
			}
		}

		if ( ! $ticket ) {
			return '';
		}

		// Tickets store only a digest of their token, so a typed code cannot
		// reconstruct it. Hand back a marker the verifier understands instead.
		return 'id:' . (int) $ticket->id;
	}

	/**
	 * Shape a verification outcome for the station.
	 *
	 * @param array $outcome  Verification payload.
	 * @param int   $event_id Event id.
	 * @return array
	 */
	private static function present( array $outcome, $event_id ) {
		$ticket   = isset( $outcome['ticket'] ) ? $outcome['ticket'] : null;
		$attendee = isset( $outcome['attendee'] ) ? $outcome['attendee'] : null;
		$order    = isset( $outcome['order'] ) ? $outcome['order'] : null;
		$result   = isset( $outcome['result'] ) ? $outcome['result'] : 'invalid';

		// These keys must match BFNET_Ticket::verify() exactly. When they did not,
		// every outcome fell through to a flat red "Refused" — a valid ticket
		// held outside the check-in window looked identical to a forgery.
		$map = array(
			'valid'          => array( 'ok', __( 'Welcome in', 'bitforgenet-guest-tickets' ) ),
			'already_used'   => array( 'warn', __( 'Already inside', 'bitforgenet-guest-tickets' ) ),
			'not_paid'       => array( 'bad', __( 'Not paid yet', 'bitforgenet-guest-tickets' ) ),
			'void'           => array( 'bad', __( 'Cancelled ticket', 'bitforgenet-guest-tickets' ) ),
			'wrong_event'    => array( 'bad', __( 'Wrong event', 'bitforgenet-guest-tickets' ) ),
			'outside_window' => array( 'warn', __( 'Doors are not open', 'bitforgenet-guest-tickets' ) ),
			'invalid'        => array( 'bad', __( 'Unknown ticket', 'bitforgenet-guest-tickets' ) ),
		);

		$state = isset( $map[ $result ] ) ? $map[ $result ][0] : 'bad';
		$title = isset( $map[ $result ] ) ? $map[ $result ][1] : __( 'Refused', 'bitforgenet-guest-tickets' );

		$name = $attendee ? BFNET_Util::full_name( $attendee->first_name, $attendee->last_name ) : '';

		$party = '';
		if ( $order && (int) $order->quantity > 1 && $attendee ) {
			/* translators: 1: guest number, 2: party size. */
			$party = sprintf( __( 'Guest %1$d of %2$d', 'bitforgenet-guest-tickets' ), (int) $attendee->position, (int) $order->quantity );
		}

		$stats = BFNET_Order::stats( $event_id );

		// "Doors are not open" is only useful if it says when they are.
		$message = isset( $outcome['message'] ) ? $outcome['message'] : '';
		if ( 'outside_window' === $result && ! empty( $outcome['event'] ) ) {
			$message = sprintf(
				/* translators: %s: date and time the doors open. */
				__( 'Doors for this event open %s.', 'bitforgenet-guest-tickets' ),
				BFNET_Util::event_date( $outcome['event'], 'starts_at', 'j M Y · H:i' )
			);
		}

		return array(
			'state'   => $state,
			'result'  => $result,
			'title'   => $title,
			'message' => $message,
			'name'    => $name,
			'party'   => $party,
			'serial'  => $ticket ? $ticket->serial : '',
			'type'    => $ticket && ! empty( $outcome['ticket_type'] ) ? $outcome['ticket_type'] : '',
			'seen_at' => $ticket && $ticket->checked_in_at ? BFNET_Util::date( $ticket->checked_in_at, 'H:i' ) : '',
			'inside'  => (int) $stats['tickets_checked_in'],
			'expected' => (int) $stats['seats_paid'],
		);
	}

	/* --------------------------------------------------------------------- */
	/* The ticket QR link                                                    */
	/* --------------------------------------------------------------------- */

	/**
	 * Handle a phone pointed at a ticket.
	 *
	 * Anyone can photograph a QR code, so an anonymous visit is told nothing about
	 * the guest and changes nothing in the database. Only a station session or a
	 * signed-in agent turns the link into an actual check-in.
	 *
	 * @param string $token Raw token from the URL.
	 * @return void
	 */
	private function handle_verify( $token ) {
		nocache_headers();

		$token    = preg_replace( '/[^A-Za-z0-9]/', '', (string) $token );
		$event_id = self::session_event();

		if ( ! $event_id ) {
			$this->render_shell(
				'neutral',
				__( 'Ticket', 'bitforgenet-guest-tickets' ),
				__( 'This ticket can only be checked in at the door station.', 'bitforgenet-guest-tickets' ),
				array(),
				array( array( self::url(), __( 'Open the door station', 'bitforgenet-guest-tickets' ) ) )
			);
			exit;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$gate    = isset( $_GET['gate'] ) ? sanitize_text_field( wp_unslash( $_GET['gate'] ) ) : '';
		$verdict = self::scan( $token, $event_id, $gate, false );

		$rows = array();
		if ( ! empty( $verdict['name'] ) ) {
			$rows[] = array( __( 'Guest', 'bitforgenet-guest-tickets' ), $verdict['name'] );
		}
		if ( ! empty( $verdict['party'] ) ) {
			$rows[] = array( __( 'Party', 'bitforgenet-guest-tickets' ), $verdict['party'] );
		}
		if ( ! empty( $verdict['serial'] ) ) {
			$rows[] = array( __( 'Ticket', 'bitforgenet-guest-tickets' ), $verdict['serial'] );
		}
		$rows[] = array( __( 'Inside', 'bitforgenet-guest-tickets' ), $verdict['inside'] . ' / ' . $verdict['expected'] );

		$this->render_shell(
			$verdict['state'],
			$verdict['title'],
			$verdict['message'],
			$rows,
			array( array( self::url(), __( 'Back to the station', 'bitforgenet-guest-tickets' ) ) )
		);
		exit;
	}

	/* --------------------------------------------------------------------- */
	/* Cookies                                                               */
	/* --------------------------------------------------------------------- */

	/**
	 * Store the session cookie.
	 *
	 * @param string $value Signed value.
	 * @return void
	 */
	private function set_cookie( $value ) {
		$secure = is_ssl();
		$path   = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );
		$path   = $path ? $path : '/';

		if ( PHP_VERSION_ID >= 70300 ) {
			setcookie(
				self::COOKIE,
				$value,
				array(
					'expires'  => time() + self::SESSION_TTL,
					'path'     => $path,
					'secure'   => $secure,
					'httponly' => true,
					'samesite' => 'Lax',
				)
			);
		} else {
			setcookie( self::COOKIE, $value, time() + self::SESSION_TTL, $path, '', $secure, true );
		}

		$_COOKIE[ self::COOKIE ] = $value;
	}

	/**
	 * Drop the session cookie.
	 *
	 * @return void
	 */
	private function clear_cookie() {
		$path = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );
		setcookie( self::COOKIE, '', time() - 3600, $path ? $path : '/' );
		unset( $_COOKIE[ self::COOKIE ] );
	}

	/* --------------------------------------------------------------------- */
	/* Screens                                                               */
	/* --------------------------------------------------------------------- */

	/**
	 * Shared page chrome for the small verdict screens.
	 *
	 * @param string $state   ok|warn|bad|neutral.
	 * @param string $title   Headline.
	 * @param string $message Sentence under it.
	 * @param array  $rows    Label/value pairs.
	 * @param array  $links   Array of [url, label].
	 * @return void
	 */
	private function render_shell( $state, $title, $message, $rows = array(), $links = array() ) {
		$colours = array(
			'ok'      => array( '#0f7b3f', '#12a050', '✓' ),
			'warn'    => array( '#8a5a00', '#c58a12', '!' ),
			'bad'     => array( '#8c1d18', '#c5221f', '✕' ),
			'neutral' => array( '#23262f', '#3a3e4a', '·' ),
		);
		$c = isset( $colours[ $state ] ) ? $colours[ $state ] : $colours['neutral'];

		wp_register_style(
			'bitforgenet-guest-tickets-door-shell',
			BFNET_URL . 'public/assets/door-shell.css',
			array(),
			BFNET_Util::asset_version( 'public/assets/door-shell.css' )
		);
		wp_enqueue_style( 'bitforgenet-guest-tickets-door-shell' );
		wp_add_inline_style(
			'bitforgenet-guest-tickets-door-shell',
			sprintf(
				':root{--bfnet-shell-a:%s;--bfnet-shell-b:%s;}',
				esc_attr( $c[0] ),
				esc_attr( $c[1] )
			)
		);

		header( 'Content-Type: text/html; charset=utf-8' );
		?>
<!doctype html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="robots" content="noindex,nofollow">
<title><?php echo esc_html( $title ); ?></title>
<?php wp_print_styles( 'bitforgenet-guest-tickets-door-shell' ); ?>
</head>
<body class="bfnet-verify-page">
<div class="w">
	<div class="mark" aria-hidden="true"><?php echo esc_html( $c[2] ); ?></div>
	<h1><?php echo esc_html( $title ); ?></h1>
	<?php if ( $message ) : ?><p class="msg"><?php echo esc_html( $message ); ?></p><?php endif; ?>
	<?php if ( $rows ) : ?>
		<div class="card">
			<?php foreach ( $rows as $row ) : ?>
				<div class="row"><span class="k"><?php echo esc_html( $row[0] ); ?></span><span><?php echo esc_html( $row[1] ); ?></span></div>
			<?php endforeach; ?>
		</div>
	<?php endif; ?>
	<?php foreach ( $links as $link ) : ?>
		<a class="btn" href="<?php echo esc_url( $link[0] ); ?>"><?php echo esc_html( $link[1] ); ?></a>
	<?php endforeach; ?>
</div>
</body>
</html>
		<?php
	}

	/**
	 * The code entry screen.
	 *
	 * @param string $error Message to show, if any.
	 * @return void
	 */
	private function render_pin_screen( $error ) {
		wp_register_style(
			'bitforgenet-guest-tickets-door-pin',
			BFNET_URL . 'public/assets/door-pin.css',
			array(),
			BFNET_Util::asset_version( 'public/assets/door-pin.css' )
		);
		wp_enqueue_style( 'bitforgenet-guest-tickets-door-pin' );

		header( 'Content-Type: text/html; charset=utf-8' );
		$logo = BFNET_URL . 'assets/images/bitforgenet-guest-tickets-logo.png';
		?>
<!doctype html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="robots" content="noindex,nofollow">
<title><?php esc_html_e( 'Door station', 'bitforgenet-guest-tickets' ); ?></title>
<?php wp_print_styles( 'bitforgenet-guest-tickets-door-pin' ); ?>
</head>
<body>
<div class="w">
	<img src="<?php echo esc_url( $logo ); ?>" alt="">
	<h1><?php esc_html_e( 'Door station', 'bitforgenet-guest-tickets' ); ?></h1>
	<p><?php esc_html_e( 'Enter the code your manager gave you for tonight.', 'bitforgenet-guest-tickets' ); ?></p>
	<?php if ( $error ) : ?><div class="err"><?php echo esc_html( $error ); ?></div><?php endif; ?>
	<form method="post" action="<?php echo esc_url( self::url() ); ?>">
		<input type="text" name="pin" maxlength="10" autocomplete="one-time-code"
			autocapitalize="characters" autocorrect="off" spellcheck="false"
			autofocus placeholder="········">
		<input type="hidden" name="bfnet_action" value="pin">
		<button type="submit"><?php esc_html_e( 'Open the station', 'bitforgenet-guest-tickets' ); ?></button>
	</form>
</div>
</body>
</html>
		<?php
	}

	/**
	 * The station itself.
	 *
	 * @param object $event Event row.
	 * @return void
	 */
	private function render_station( $event ) {
		$stats = BFNET_Order::stats( (int) $event->id );
		$gates = BFNET_Util::gates();

		$config = array(
			'endpoint' => self::url( array( 'event' => (int) $event->id ) ),
			'event'    => (int) $event->id,
			'title'    => $event->title,
			'when'     => BFNET_Util::event_date( $event, 'starts_at', 'l j F · H:i' ),
			'inside'   => (int) $stats['tickets_checked_in'],
			'expected' => (int) $stats['seats_paid'],
			'gates'    => array_values( $gates ),
			'sound'    => (bool) BFNET_Settings::get( 'scan_sound', 1 ),
		);

		wp_register_style(
			'bitforgenet-guest-tickets-door',
			BFNET_URL . 'public/assets/door.css',
			array(),
			BFNET_Util::asset_version( 'public/assets/door.css' )
		);
		wp_register_script(
			'bitforgenet-guest-tickets-qr-decode',
			BFNET_URL . 'admin/assets/bfnet-qr-decode.js',
			array(),
			BFNET_Util::asset_version( 'admin/assets/bfnet-qr-decode.js' ),
			true
		);
		wp_register_script(
			'bitforgenet-guest-tickets-door',
			BFNET_URL . 'public/assets/door.js',
			array( 'bitforgenet-guest-tickets-qr-decode' ),
			BFNET_Util::asset_version( 'public/assets/door.js' ),
			true
		);

		wp_enqueue_style( 'bitforgenet-guest-tickets-door' );
		wp_enqueue_script( 'bitforgenet-guest-tickets-door' );
		wp_add_inline_script(
			'bitforgenet-guest-tickets-door',
			'window.BFNETDoorConfig = ' . wp_json_encode( $config ) . ';',
			'before'
		);

		header( 'Content-Type: text/html; charset=utf-8' );
		?>
<!doctype html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="robots" content="noindex,nofollow">
<meta name="theme-color" content="#12141a">
<title><?php echo esc_html( $event->title ); ?> — <?php esc_html_e( 'Door', 'bitforgenet-guest-tickets' ); ?></title>
<?php wp_print_styles( 'bitforgenet-guest-tickets-door' ); ?>
</head>
<body class="bfnet-door">
<?php require BFNET_PATH . 'public/views/door-station.php'; ?>
<?php wp_print_scripts( 'bitforgenet-guest-tickets-door' ); ?>
</body>
</html>
		<?php
	}
}
