<?php
/**
 * BitForgeNet Guest Tickets - The paper door list.
 *
 * Every electronic door needs a paper one behind it. When the wifi dies, the
 * phone dies, or the host's server picks that evening to be slow, the night does
 * not stop — the agent falls back to a printed list, a pen, and a tick box.
 *
 * The list is deliberately ordered by surname rather than by booking, because at
 * a door you are handed a name, not a reference. Guests on the same booking still
 * carry their party marker, so a family that arrives together is obvious.
 *
 * @package BitForgeNet_Guest_Tickets
 * @since   1.3.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


/**
 * Builds the printable check-in sheet.
 */
class BFNET_Roll_Call {

	const INK    = array( 22, 24, 30 );
	const MUTED  = array( 118, 122, 130 );
	const GOLD   = array( 176, 138, 26 );
	const RULE   = array( 208, 210, 216 );
	const STRIPE = array( 246, 246, 248 );
	const PAPER  = array( 255, 255, 255 );

	/**
	 * Which bookings to include.
	 *
	 * @return array
	 */
	public static function filters() {
		return array(
			'paid' => __( 'Paid bookings only', 'bitforgenet-guest-tickets' ),
			'all'  => __( 'Every booking, paid or not', 'bitforgenet-guest-tickets' ),
		);
	}

	/**
	 * Build the sheet.
	 *
	 * @param int    $event_id Event id.
	 * @param string $filter   paid|all.
	 * @return string|WP_Error PDF bytes.
	 */
	public static function build( $event_id, $filter = 'paid' ) {
		$event = BFNET_Event::get( (int) $event_id );
		if ( ! $event ) {
			return new WP_Error( 'bfnet_no_event', __( 'That event no longer exists.', 'bitforgenet-guest-tickets' ) );
		}

		$rows = self::rows( (int) $event_id, $filter );

		// Landscape. The sheet now carries a table number as well as a name, a
		// ticket and a booking, and a portrait page forces those into columns too
		// narrow to read at a doorway.
		$sheet = new BFNET_Sheet( 297, 210 );
		$sheet->set_meta(
			array(
				'title'  => sprintf( '%s — door list', $event->title ),
				'author' => (string) BFNET_Settings::get( 'brand_name', 'BitForgeNet Guest Tickets' ),
			)
		);

		$pages  = array();
		$page   = array();
		$first  = true;
		$limit  = 13;
		$limit2 = 17;

		foreach ( $rows as $row ) {
			$page[] = $row;
			$cap    = $first ? $limit : $limit2;
			if ( count( $page ) >= $cap ) {
				$pages[] = $page;
				$page    = array();
				$first   = false;
			}
		}
		if ( $page || ! $pages ) {
			$pages[] = $page;
		}

		$total = count( $pages );

		// Counted once, printed on the cover line: a door manager wants to know
		// how many of the names in front of him have actually paid before the
		// first guest arrives, not after counting the sheet by hand.
		$paid = 0;
		foreach ( $rows as $row ) {
			if ( 'paid' === $row->payment_status ) {
				++$paid;
			}
		}

		foreach ( $pages as $index => $chunk ) {
			if ( $index > 0 ) {
				$sheet->new_page();
			}
			self::page( $sheet, $event, $chunk, $filter, $index + 1, $total, count( $rows ), 0 === $index, $paid );
		}

		return $sheet->render();
	}

	/**
	 * Gather the guests, ordered the way a door works.
	 *
	 * @param int    $event_id Event id.
	 * @param string $filter   paid|all.
	 * @return array
	 */
	private static function rows( $event_id, $filter ) {
		global $wpdb;

		$tickets   = BFNET_Install::table( 'tickets' );
		$attendees = BFNET_Install::table( 'attendees' );
		$orders    = BFNET_Install::table( 'orders' );

		// Guests, not tickets. A ticket only exists once a payment has been
		// validated, so building the list from tickets made "every booking, paid
		// or not" impossible to produce — and left the sheet blank on an event
		// where nothing had been validated yet. Attendees exist from the moment
		// someone books, which is exactly who a door list is about.
		$has_table = in_array( 'table_number', BFNET_Install::columns( $attendees ), true );
		$table_col = $has_table ? 'a.table_number' : "'' AS table_number";

		$where = 'a.event_id = %d';
		$args  = array( (int) $event_id );

		if ( 'paid' === $filter ) {
			$where .= " AND o.payment_status = 'paid'";
		}

		$sql = "SELECT
				a.first_name, a.last_name, a.position, a.category, {$table_col},
				o.reference, o.quantity, o.payment_status,
				TRIM(CONCAT(COALESCE(o.first_name,''), ' ', COALESCE(o.last_name,''))) AS buyer_name,
				t.serial, t.code, t.checked_in, t.status AS ticket_status
			FROM {$attendees} a
			LEFT JOIN {$orders} o ON o.id = a.order_id
			LEFT JOIN {$tickets} t ON t.attendee_id = a.id AND t.status <> 'void'
			WHERE {$where}
			ORDER BY a.last_name ASC, a.first_name ASC, a.position ASC";

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

	/**
	 * The payment badge for one guest.
	 *
	 * @param BFNET_Sheet $sheet  Document.
	 * @param float     $x      Left edge, in mm.
	 * @param float     $top    Top of the row, in mm.
	 * @param string    $status Payment status as stored.
	 * @return void
	 */
	private static function stamp( $sheet, $x, $top, $status ) {
		$status = strtolower( trim( $status ) );

		$known = array(
			'paid'      => array( __( 'PAID', 'bitforgenet-guest-tickets' ), array( 16, 118, 60 ), array( 255, 255, 255 ) ),
			'pending'   => array( __( 'UNPAID', 'bitforgenet-guest-tickets' ), array( 176, 32, 28 ), array( 255, 255, 255 ) ),
			'cancelled' => array( __( 'CANCELLED', 'bitforgenet-guest-tickets' ), array( 90, 92, 100 ), array( 255, 255, 255 ) ),
			'refunded'  => array( __( 'REFUNDED', 'bitforgenet-guest-tickets' ), array( 138, 90, 0 ), array( 255, 255, 255 ) ),
			'expired'   => array( __( 'EXPIRED', 'bitforgenet-guest-tickets' ), array( 90, 92, 100 ), array( 255, 255, 255 ) ),
		);

		// An unknown status is still printed rather than swallowed: whatever the
		// database says, the door sees it.
		$badge = isset( $known[ $status ] )
			? $known[ $status ]
			: array( strtoupper( '' !== $status ? $status : __( 'UNKNOWN', 'bitforgenet-guest-tickets' ) ), array( 176, 32, 28 ), array( 255, 255, 255 ) );

		$sheet->rect( $x, $top + 2.1, 22, 4.8, $badge[1] );
		$sheet->text_clipped( $x + 1.6, $top + 5.5, $badge[0], 19.5, 6.2, true, $badge[2] );
	}

	/**
	 * Draw one page.
	 *
	 * @param BFNET_Sheet $sheet  Document.
	 * @param object    $event  Event row.
	 * @param array     $rows   Guests on this page.
	 * @param string    $filter Filter applied.
	 * @param int       $page   Page number.
	 * @param int       $total  Total pages.
	 * @param int       $guests Total guests across every page.
	 * @param bool      $first  Whether this is the first page.
	 * @param int       $paid   How many of those guests are on a paid booking.
	 * @return void
	 */
	private static function page( $sheet, $event, $rows, $filter, $page, $total, $guests, $first, $paid = 0 ) {
		$left  = 14;
		$right = 283;
		$width = $right - $left;
		$y     = 16;

		if ( $first ) {
			// Masthead.
			$sheet->rect( 0, 0, 297, 34, self::INK );
			$sheet->text( $left, 13, strtoupper( (string) BFNET_Settings::get( 'brand_name', 'BitForgeNet Guest Tickets' ) ), 8, true, self::GOLD, 1.6 );
			$sheet->text_clipped( $left, 24, $event->title, 190, 17, true, array( 255, 255, 255 ) );

			$series = trim( (string) $event->series . ( $event->edition ? ' · ' . $event->edition : '' ) );
			if ( '' !== $series ) {
				$sheet->text_clipped( $left, 30, $series, 190, 8.5, false, array( 176, 178, 186 ) );
			}

			$sheet->text( $right - 46, 13, __( 'DOOR LIST', 'bitforgenet-guest-tickets' ), 8, true, self::GOLD, 1.6 );
			$sheet->text( $right - 46, 24, BFNET_Util::event_date( $event, 'starts_at', 'j M Y' ), 12, true, array( 255, 255, 255 ) );
			$sheet->text( $right - 46, 30, BFNET_Util::event_date( $event, 'starts_at', 'H:i' ) . ' ' . BFNET_Util::tz_abbr( $event->starts_at, BFNET_Util::event_timezone( $event ) ), 8.5, false, array( 176, 178, 186 ) );

			$y = 44;

			// The facts an agent might be asked at the door.
			$venue = ! empty( $event->venue_id ) ? BFNET_Venue::get( (int) $event->venue_id ) : null;
			$facts = array(
				array( __( 'VENUE', 'bitforgenet-guest-tickets' ), $venue ? $venue->name : '—' ),
				array( __( 'DOORS', 'bitforgenet-guest-tickets' ), ! empty( $event->doors_open_at ) ? BFNET_Util::event_date( $event, 'doors_open_at', 'H:i' ) : BFNET_Util::event_date( $event, 'starts_at', 'H:i' ) ),
				array( __( 'GUESTS EXPECTED', 'bitforgenet-guest-tickets' ), (string) $guests ),
				array(
					__( 'PAID', 'bitforgenet-guest-tickets' ),
					sprintf(
						/* translators: 1: paid guests, 2: total guests. */
						__( '%1$d of %2$d', 'bitforgenet-guest-tickets' ),
						(int) $paid,
						(int) $guests
					),
				),
				array( __( 'LIST', 'bitforgenet-guest-tickets' ), 'paid' === $filter ? __( 'Paid only', 'bitforgenet-guest-tickets' ) : __( 'All bookings', 'bitforgenet-guest-tickets' ) ),
			);

			/**
			 * Facts an agent might be asked at the door.
			 *
			 * A module that knows something the door needs — which tables are in
			 * use, say — adds a pair here. Anything added shares the width.
			 *
			 * @param array  $facts  Label/value pairs.
			 * @param object $event  The event.
			 */
			$facts = (array) apply_filters( 'bfnet_roll_call_facts', $facts, $event );

			// The venue is the one fact with a long value, so it keeps a third of
			// the width; whatever else there is shares the rest evenly. Computed
			// rather than listed, so a module adding a fact cannot push the last
			// column off the page.
			$spans = array( 0.32 );
			$share = count( $facts ) > 1 ? 0.68 / ( count( $facts ) - 1 ) : 0;
			for ( $i = 1; $i < count( $facts ); $i++ ) {
				$spans[] = $share;
			}
			$x     = $left;
			foreach ( $facts as $i => $fact ) {
				$span = $width * $spans[ $i ];
				$sheet->text( $x, $y, $fact[0], 6.5, true, self::GOLD, 1.1 );
				$sheet->text_clipped( $x, $y + 6, $fact[1], $span - 4, 10, false, self::INK );
				$x += $span;
			}

			$y += 13;
			$sheet->rule( $left, $y, $width, self::RULE, 0.4 );
			$y += 9;
		} else {
			$sheet->text_clipped( $left, $y, $event->title, 180, 11, true, self::INK );
			$sheet->text( $right - 40, $y, BFNET_Util::event_date( $event, 'starts_at', 'j M Y' ), 9, false, self::MUTED );
			$y += 4;
			$sheet->rule( $left, $y, $width, self::RULE, 0.4 );
			$y += 9;
		}

		// Column geometry: tick box, name, table, ticket, booking, party,
		// category, payment, time in. Payment sits immediately before the
		// signature line because those two are read together: an agent looks at
		// whether the guest has paid, then writes the time.
		$c_box   = $left;
		$c_name  = $left + 13;
		$c_table = $left + 96;
		$c_tick  = $left + 121;
		$c_ref   = $left + 156;
		$c_pty   = $left + 182;
		$c_cat   = $left + 193;
		$c_paid  = $left + 215;
		$c_sig   = $left + 241;

		$sheet->text( $c_box, $y, __( 'IN', 'bitforgenet-guest-tickets' ), 6.5, true, self::MUTED, 1 );
		$sheet->text( $c_name, $y, __( 'GUEST', 'bitforgenet-guest-tickets' ), 6.5, true, self::MUTED, 1 );
		$sheet->text( $c_table, $y, __( 'TABLE', 'bitforgenet-guest-tickets' ), 6.5, true, self::GOLD, 1 );
		$sheet->text( $c_tick, $y, __( 'TICKET', 'bitforgenet-guest-tickets' ), 6.5, true, self::MUTED, 1 );
		$sheet->text( $c_ref, $y, __( 'BOOKING', 'bitforgenet-guest-tickets' ), 6.5, true, self::MUTED, 1 );
		$sheet->text( $c_pty, $y, __( 'PTY', 'bitforgenet-guest-tickets' ), 6.5, true, self::MUTED, 1 );
		$sheet->text( $c_cat, $y, __( 'CATEGORY', 'bitforgenet-guest-tickets' ), 6.5, true, self::MUTED, 1 );
		$sheet->text( $c_paid, $y, __( 'PAYMENT', 'bitforgenet-guest-tickets' ), 6.5, true, self::MUTED, 1 );
		$sheet->text( $c_sig, $y, __( 'TIME IN', 'bitforgenet-guest-tickets' ), 6.5, true, self::MUTED, 1 );

		$y += 2.5;
		$sheet->rule( $left, $y, $width, self::INK, 0.5 );
		$y += 1;

		$row_h = 8.6;

		if ( ! $rows ) {
			$sheet->text( $left, $y + 12, __( 'No guests match this list yet.', 'bitforgenet-guest-tickets' ), 10, false, self::MUTED );
		}

		foreach ( $rows as $i => $row ) {
			$top = $y + ( $i * $row_h );

			if ( 0 === $i % 2 ) {
				$sheet->rect( $left, $top, $width, $row_h, self::STRIPE );
			}

			$base = $top + 5.2;

			$sheet->box( $c_box + 1, $top + 2, 5 );

			$name = trim( (string) $row->last_name . ', ' . (string) $row->first_name );
			$name = trim( $name, ', ' );
			if ( '' === $name ) {
				$name = (string) $row->buyer_name;
			}
			$sheet->text_clipped( $c_name, $base, $name, 80, 10, true, self::INK );

			// The table is what an usher reads off this sheet, so it is set like
			// a heading rather than like data.
			if ( ! empty( $row->table_number ) ) {
				$sheet->text_clipped( $c_table, $base, (string) $row->table_number, 23, 12, true, array( 20, 20, 20 ) );
			} else {
				$sheet->text( $c_table, $base, '—', 10, false, self::MUTED );
			}

			if ( $row->serial ) {
				$sheet->text_clipped( $c_tick, $base, (string) $row->serial, 33, 10, true, self::INK );
			} else {
				$sheet->text( $c_tick, $base, __( 'not issued', 'bitforgenet-guest-tickets' ), 8.5, false, self::MUTED );
			}
			$sheet->text_clipped( $c_ref, $base, (string) $row->reference, 24, 8.5, false, self::MUTED );

			$party = ( (int) $row->quantity > 1 )
				? sprintf( '%d/%d', max( 1, (int) $row->position ), (int) $row->quantity )
				: '1';
			$sheet->text( $c_pty, $base, $party, 9, (int) $row->quantity > 1, self::INK );

			$sheet->text_clipped( $c_cat, $base, (string) $row->category, 20, 8.5, false, self::MUTED );

			// Payment status, printed as a filled badge rather than as a word in
			// a column of words. On a sheet read at arm's length under bad light
			// the colour is what carries — an unpaid guest must be impossible to
			// miss, and a paid one must be impossible to mistake for unpaid.
			self::stamp( $sheet, $c_paid, $top, (string) $row->payment_status );

			// The signature line stays a line: whatever the payment says, the
			// agent still writes the time the guest walked in.
			$sheet->rule( $c_sig, $base + 1, 26, self::RULE, 0.25 );

			$sheet->rule( $left, $top + $row_h, $width, self::RULE, 0.15 );
		}

		// Footer.
		$foot = 199;
		$sheet->rule( $left, $foot - 5, $width, self::RULE, 0.3 );
		$sheet->text(
			$left,
			$foot,
			sprintf(
				/* translators: 1: date and time the sheet was printed. */
				__( 'Printed %s', 'bitforgenet-guest-tickets' ),
				BFNET_Util::event_date( $event, 'starts_at', '' ) ? BFNET_Util::date( current_time( 'mysql' ), 'j M Y · H:i' ) : ''
			),
			7.5,
			false,
			self::MUTED
		);
		$sheet->text( $left + 90, $foot, __( 'Door manager signature', 'bitforgenet-guest-tickets' ), 7.5, false, self::MUTED );
		$sheet->rule( $left + 130, $foot + 0.6, 55, self::RULE, 0.3 );
		$sheet->text(
			$right - 24,
			$foot,
			sprintf(
				/* translators: 1: page number, 2: page count. */
				__( 'Page %1$d of %2$d', 'bitforgenet-guest-tickets' ),
				$page,
				$total
			),
			7.5,
			true,
			self::MUTED
		);
	}
}
