<?php
/**
 * BitForgeNet Guest Tickets - Ticket renderer.
 *
 * Takes a template background, engraves the attendee's data and their unique QR
 * code onto it with GD, then wraps the result in a PDF at the exact physical size.
 *
 * @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


/**
 * Renders individual and bundled ticket PDFs.
 */
class BFNET_Ticket_Renderer {

	/**
	 * Build the data map used to fill a template.
	 *
	 * @param object      $ticket   Ticket row.
	 * @param string      $token    Raw QR token.
	 * @param object|null $attendee Optional pre-loaded attendee.
	 * @return array
	 */
	public static function context( $ticket, $token, $attendee = null ) {
		$order     = BFNET_Order::get( $ticket->order_id );
		$event     = BFNET_Event::get( $ticket->event_id );
		$attendee  = $attendee ? $attendee : BFNET_Ticket::attendee( $ticket );
		$venue     = $event ? BFNET_Venue::get( $event->venue_id ) : null;
		$organizer = $event ? BFNET_Organizer::get( $event->organizer_id ) : null;
		$type      = BFNET_Offers::tier( $ticket->ticket_type_id );

		$lineup = array();
		if ( $event ) {
			foreach ( BFNET_Event::lineup( $event ) as $item ) {
				$lineup[] = trim( $item['name'] . ( ! empty( $item['role'] ) ? ' (' . $item['role'] . ')' : '' ) );
			}
		}

		$position = $attendee ? (int) $attendee->position : 1;
		$total    = $order ? (int) $order->quantity : 1;

		return array(
			'event_series'       => $event ? $event->series : '',
			'event_title'        => $event ? $event->title : '',
			'event_subtitle'     => $event ? $event->subtitle : '',
			'event_edition'      => $event ? $event->edition : '',
			'attendee_name'      => $attendee ? BFNET_Util::full_name( $attendee->first_name, $attendee->last_name ) : '',
			'attendee_first'     => $attendee ? $attendee->first_name : '',
			'attendee_last'      => $attendee ? $attendee->last_name : '',
			'ticket_serial'      => $ticket->serial,
			'table_number'       => ( $attendee && isset( $attendee->table_number ) && '' !== trim( (string) $attendee->table_number ) )
				/* translators: %s: table number. */
				? sprintf( __( 'TABLE %s', 'bitforgenet-guest-tickets' ), $attendee->table_number )
				: '',
			'ticket_code'        => $ticket->code,
			'ticket_type'        => $type ? $type->name : '',
			'order_reference'    => $order ? $order->reference : '',
			/* translators: 1: guest number, 2: total guests on the order. */
			'seat_index'         => $total > 1 ? sprintf( __( 'Guest %1$d of %2$d', 'bitforgenet-guest-tickets' ), $position, $total ) : '',
			'event_date'         => $event ? BFNET_Util::date( $event->starts_at, 'l j F Y' ) : '',
			'event_time'         => $event ? BFNET_Util::date( $event->starts_at, get_option( 'time_format' ) ) : '',
			'doors_open'         => ( $event && ! empty( $event->doors_open_at ) ) ? BFNET_Util::date( $event->doors_open_at, get_option( 'time_format' ) ) : '',
			'venue_name'         => $venue ? $venue->name : '',
			'venue_address'      => $venue ? BFNET_Venue::address_line( $venue ) : '',
			'venue_city'         => $venue ? $venue->city : '',
			'organizer_name'     => $organizer ? $organizer->name : (string) BFNET_Settings::get( 'brand_name', 'BitForgeNet Guest Tickets' ),
			'organizer_phone'    => $organizer ? $organizer->phone : '',
			'organizer_email'    => $organizer ? $organizer->email : '',
			'organizer_whatsapp' => $organizer && $organizer->whatsapp ? $organizer->whatsapp : ( $event ? $event->whatsapp_number : '' ),
			'lineup'             => implode( ' · ', array_slice( $lineup, 0, 4 ) ),
			'dress_code'         => $event ? $event->dress_code : '',
			'price'              => BFNET_Util::money( BFNET_Offers::price( $event, $type ), $order ? $order->currency : '' ),
			'qr'                 => BFNET_Ticket::verify_url( $token ),
			'logo'               => BFNET_Organizer::logo_url( $organizer ),
			'_organizer'         => $organizer,
			'_event'             => $event,
		);
	}

	/**
	 * Compose the ticket artwork.
	 *
	 * @param object $ticket Ticket row.
	 * @param string $token  Raw QR token.
	 * @return resource|GdImage|WP_Error
	 */
	public static function compose( $ticket, $token ) {
		$template = BFNET_Template::get( $ticket->template_id );
		if ( ! $template ) {
			$template = BFNET_Template::get_default();
		}

		$width  = $template ? (int) $template->width_px : BFNET_Template::WIDTH_PX;
		$height = $template ? (int) $template->height_px : BFNET_Template::HEIGHT_PX;

		$canvas = null;

		// A 1181 x 2953 truecolour canvas is about 14 MB in GD, and a background of
		// the same size is another. On a host with a modest memory limit the render
		// simply stops, which from the outside looks like a preview that produces
		// nothing at all.
		if ( function_exists( 'wp_raise_memory_limit' ) ) {
			wp_raise_memory_limit( 'image' );
		}

		if ( $template ) {
			$background = BFNET_Template::background_path( $template );
			$canvas     = BFNET_Canvas::load( $background );
		}

		if ( ! $canvas ) {
			// Last-resort background so a ticket is always deliverable.
			$painter = new BFNET_Template_Painter( BFNET_Template::palettes()['royal_gold'], $width, $height );
			$canvas  = $painter->paint();
		}

		if ( ! $canvas ) {
			return new WP_Error( 'bfnet_render', __( 'The ticket artwork could not be created.', 'bitforgenet-guest-tickets' ) );
		}

		// Normalise the canvas size in case a designer uploaded a slightly different image.
		if ( imagesx( $canvas ) !== $width || imagesy( $canvas ) !== $height ) {
			$resized = imagecreatetruecolor( $width, $height );
			imagecopyresampled( $resized, $canvas, 0, 0, 0, 0, $width, $height, imagesx( $canvas ), imagesy( $canvas ) );
			imagedestroy( $canvas );
			$canvas = $resized;
		}

		imagealphablending( $canvas, true );
		imagesavealpha( $canvas, false );

		$map     = BFNET_Template::scaled_fieldmap( $template );
		$context = self::context( $ticket, $token );

		foreach ( $map['fields'] as $field ) {
			$box = array(
				'x' => (int) $field['x'],
				'y' => (int) $field['y'],
				'w' => (int) $field['w'],
				'h' => (int) $field['h'],
			);

			switch ( $field['type'] ) {
				case 'qr':
					self::draw_qr( $canvas, $context['qr'], $box, $field );
					break;

				case 'image':
					self::draw_image( $canvas, $context, $field, $box );
					break;

				case 'text':
				default:
					$value = ( 'static' === $field['key'] )
						? ( isset( $field['value'] ) ? $field['value'] : '' )
						: ( isset( $context[ $field['key'] ] ) ? $context[ $field['key'] ] : '' );

					if ( '' === trim( (string) $value ) ) {
						break;
					}

					BFNET_Canvas::draw_text_block( $canvas, $value, $box, $field );
					break;
			}
		}

		/**
		 * Fires after all template fields have been engraved, before rasterising.
		 *
		 * @param resource|GdImage $canvas  Ticket image.
		 * @param object           $ticket  Ticket row.
		 * @param array            $context Resolved field values.
		 */
		do_action( 'bfnet_ticket_composed', $canvas, $ticket, $context );

		return $canvas;
	}

	/**
	 * Draw the QR code into its box.
	 *
	 * @param resource|GdImage $canvas Canvas.
	 * @param string           $url    Payload.
	 * @param array            $box    Target box.
	 * @param array            $field  Field definition.
	 * @return void
	 */
	private static function draw_qr( $canvas, $url, array $box, array $field ) {
		$levels = array(
			'L' => BFNET_QR::ECC_L,
			'M' => BFNET_QR::ECC_M,
			'Q' => BFNET_QR::ECC_Q,
			'H' => BFNET_QR::ECC_H,
		);
		$level  = (string) BFNET_Settings::get( 'qr_ecc', 'Q' );
		$ecc    = isset( $levels[ $level ] ) ? $levels[ $level ] : BFNET_QR::ECC_Q;

		$quiet = isset( $field['quiet'] ) ? (int) $field['quiet'] : 2;
		$dark  = isset( $field['dark'] ) ? BFNET_Canvas::rgb( $field['dark'] ) : array( 0, 0, 0 );
		$light = isset( $field['light'] ) ? BFNET_Canvas::rgb( $field['light'] ) : array( 255, 255, 255 );

		try {
			$size = min( (int) $box['w'], (int) $box['h'] );
			$qr   = BFNET_QR::image( $url, $size, $ecc, $quiet, $dark, $light );
		} catch ( Exception $e ) {
			BFNET_Log::error( 'qr_render_failed', $e->getMessage(), array( 'url_length' => strlen( $url ) ) );
			return;
		}

		// Nearest-neighbour placement keeps module edges perfectly crisp.
		$qw = imagesx( $qr );
		$dx = (int) $box['x'] + (int) round( ( (int) $box['w'] - $qw ) / 2 );
		$dy = (int) $box['y'] + (int) round( ( (int) $box['h'] - $qw ) / 2 );

		imagealphablending( $canvas, false );
		imagecopy( $canvas, $qr, $dx, $dy, 0, 0, $qw, $qw );
		imagealphablending( $canvas, true );

		imagedestroy( $qr );
	}

	/**
	 * Draw an image field (currently the organizer logo).
	 *
	 * @param resource|GdImage $canvas  Canvas.
	 * @param array            $context Field values.
	 * @param array            $field   Field definition.
	 * @param array            $box     Target box.
	 * @return void
	 */
	private static function draw_image( $canvas, array $context, array $field, array $box ) {
		$path = '';

		if ( 'logo' === $field['key'] ) {
			$organizer = isset( $context['_organizer'] ) ? $context['_organizer'] : null;
			if ( $organizer && ! empty( $organizer->logo_id ) ) {
				$file = get_attached_file( (int) $organizer->logo_id );
				if ( $file && is_readable( $file ) ) {
					$path = $file;
				}
			}
			if ( ! $path ) {
				$path = BFNET_PATH . 'assets/images/bitforgenet-guest-tickets-logo.png';
			}
		}

		// The sponsor strip. Nothing is drawn when an event has no sponsors, so
		// the space beneath the QR simply stays as the artwork designed it.
		if ( 'sponsors' === $field['key'] ) {
			$id = isset( $context['_event'] ) && ! empty( $context['_event']->sponsors_image )
				? (int) $context['_event']->sponsors_image
				: 0;

			if ( ! $id ) {
				return;
			}

			$file = get_attached_file( $id );
			if ( ! $file || ! is_readable( $file ) ) {
				return;
			}

			$path = $file;
		}

		$img = BFNET_Canvas::load( $path );
		if ( ! $img ) {
			return;
		}

		BFNET_Canvas::place( $canvas, $img, $box, isset( $field['fit'] ) ? $field['fit'] : 'contain' );
		imagedestroy( $img );
	}

	/* --------------------------------------------------------------------- */
	/* Output                                                                */
	/* --------------------------------------------------------------------- */

	/**
	 * JPEG quality used inside the PDF.
	 *
	 * This is the single biggest lever on email deliverability: at quality 92 a family of
	 * five produces roughly 6 MB of attachments, which many mail servers silently strip.
	 * 80 keeps the ticket visually identical in print while roughly halving the payload.
	 *
	 * @return int
	 */
	public static function jpeg_quality() {
		return max( 40, min( 100, (int) BFNET_Settings::get( 'pdf_quality', 80 ) ) );
	}

	/**
	 * Physical page size for a template, in millimetres.
	 *
	 * @param object|null $template Template row.
	 * @return array [width_mm, height_mm]
	 */
	public static function page_mm( $template ) {
		$dpi = $template ? max( 72, (int) $template->dpi ) : BFNET_Template::DPI;
		$w   = $template ? (int) $template->width_px : BFNET_Template::WIDTH_PX;
		$h   = $template ? (int) $template->height_px : BFNET_Template::HEIGHT_PX;

		return array(
			round( ( $w / $dpi ) * 25.4, 3 ),
			round( ( $h / $dpi ) * 25.4, 3 ),
		);
	}

	/**
	 * Render one ticket to a PDF on disk and record it on the ticket row.
	 *
	 * @param object $ticket Ticket row.
	 * @param string $token  Raw QR token.
	 * @return string|WP_Error Absolute path to the PDF.
	 */
	public static function render_pdf( $ticket, $token ) {
		global $wpdb;

		$canvas = self::compose( $ticket, $token );
		if ( is_wp_error( $canvas ) ) {
			return $canvas;
		}

		ob_start();
		imagejpeg( $canvas, null, self::jpeg_quality() );
		$jpeg = ob_get_clean();

		$template            = BFNET_Template::get( $ticket->template_id );
		list( $mm_w, $mm_h ) = self::page_mm( $template );
		$px_w                = imagesx( $canvas );
		$px_h                = imagesy( $canvas );
		imagedestroy( $canvas );

		$event = BFNET_Event::get( $ticket->event_id );

		$pdf = new BFNET_PDF();
		$pdf->set_meta(
			array(
				'title'    => sprintf( '%s — %s', $event ? $event->title : 'Ticket', $ticket->serial ),
				'author'   => (string) BFNET_Settings::get( 'brand_name', 'BitForgeNet Guest Tickets' ),
				'subject'  => __( 'Event ticket', 'bitforgenet-guest-tickets' ),
				'keywords' => $ticket->code,
			)
		);
		$pdf->add_jpeg_page( $jpeg, $px_w, $px_h, $mm_w, $mm_h );

		$storage  = BFNET_Install::storage();
		$filename = 'ticket-' . sanitize_file_name( $ticket->code ) . '-' . substr( md5( $ticket->uuid . wp_salt() ), 0, 10 ) . '.pdf';
		$path     = $storage['dir'] . '/tickets/' . $filename;

		if ( ! $pdf->save( $path ) ) {
			return new WP_Error( 'bfnet_pdf_write', __( 'The ticket PDF could not be written to disk. Check the uploads folder permissions.', 'bitforgenet-guest-tickets' ) );
		}

		$wpdb->update(
			BFNET_Ticket::table(),
			array( 'pdf_file' => $filename, 'pdf_generated_at' => current_time( 'mysql' ) ),
			array( 'id' => (int) $ticket->id ),
			array( '%s', '%s' ),
			array( '%d' )
		);

		return $path;
	}

	/**
	 * Render every ticket of an order into a single multi-page PDF.
	 *
	 * @param int   $order_id Order id.
	 * @param array $issued   Map of ticket_id => ['ticket' => object, 'token' => string].
	 * @return string|WP_Error Absolute path.
	 */
	public static function render_bundle( $order_id, array $issued ) {
		$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 );

		$pdf = new BFNET_PDF();
		$pdf->set_meta(
			array(
				'title'    => sprintf( '%s — %s', $event ? $event->title : 'Tickets', $order->reference ),
				'author'   => (string) BFNET_Settings::get( 'brand_name', 'BitForgeNet Guest Tickets' ),
				'subject'  => __( 'Event tickets', 'bitforgenet-guest-tickets' ),
				'keywords' => $order->reference,
			)
		);

		foreach ( $issued as $entry ) {
			$canvas = self::compose( $entry['ticket'], $entry['token'] );
			if ( is_wp_error( $canvas ) ) {
				continue;
			}

			ob_start();
			imagejpeg( $canvas, null, self::jpeg_quality() );
			$jpeg = ob_get_clean();

			$template            = BFNET_Template::get( $entry['ticket']->template_id );
			list( $mm_w, $mm_h ) = self::page_mm( $template );

			$pdf->add_jpeg_page( $jpeg, imagesx( $canvas ), imagesy( $canvas ), $mm_w, $mm_h );
			imagedestroy( $canvas );
		}

		if ( ! $pdf->page_count() ) {
			return new WP_Error( 'bfnet_no_pages', __( 'No ticket could be rendered for this order.', 'bitforgenet-guest-tickets' ) );
		}

		$storage  = BFNET_Install::storage();
		$filename = 'order-' . sanitize_file_name( $order->reference ) . '-' . substr( md5( $order->uuid . wp_salt() ), 0, 10 ) . '.pdf';
		$path     = $storage['dir'] . '/tickets/' . $filename;

		if ( ! $pdf->save( $path ) ) {
			return new WP_Error( 'bfnet_pdf_write', __( 'The ticket bundle could not be written to disk.', 'bitforgenet-guest-tickets' ) );
		}

		return $path;
	}

	/**
	 * Render a demo ticket for the template preview screen.
	 *
	 * @param object $template Template row.
	 * @return string|WP_Error Binary JPEG data.
	 */
	public static function render_sample( $template ) {
		$canvas = BFNET_Canvas::load( BFNET_Template::background_path( $template ) );

		if ( ! $canvas ) {
			$palette = BFNET_Util::json( $template->palette );
			$painter = new BFNET_Template_Painter( $palette ? $palette : BFNET_Template::palettes()['royal_gold'], (int) $template->width_px, (int) $template->height_px );
			$canvas  = $painter->paint();
		}

		if ( ! $canvas ) {
			return new WP_Error(
				'bfnet_sample',
				__( 'The design file could not be opened. It may have failed to upload, or be in a format this server cannot read.', 'bitforgenet-guest-tickets' )
			);
		}

		// The proof has to be drawn on the same canvas the real ticket uses, or
		// it proves nothing.
		$want_w = (int) $template->width_px;
		$want_h = (int) $template->height_px;

		if ( $want_w > 0 && $want_h > 0 && ( imagesx( $canvas ) !== $want_w || imagesy( $canvas ) !== $want_h ) ) {
			$resized = imagecreatetruecolor( $want_w, $want_h );
			imagecopyresampled( $resized, $canvas, 0, 0, 0, 0, $want_w, $want_h, imagesx( $canvas ), imagesy( $canvas ) );
			imagedestroy( $canvas );
			$canvas = $resized;
		}

		imagealphablending( $canvas, true );

		$organizer = BFNET_Organizer::primary();
		$sample    = array(
			'event_series'       => 'Friday Sessions',
			'event_title'        => 'Opening Night',
			'event_subtitle'     => 'An evening of music and dancing',
			'event_edition'      => 'No. 7',
			'attendee_name'      => 'Alex Morgan',
			'attendee_first'     => 'Alex',
			'attendee_last'      => 'Morgan',
			'ticket_serial'      => 'BFNET-000042',
			'ticket_code'        => 'BFNET-000042',
			'ticket_type'        => 'VIP table',
			'table_number'       => 'TABLE 12',
			'order_reference'    => 'BFNET-2026-4KD9QW',
			'seat_index'         => 'Guest 2 of 5',
			'event_date'         => 'Saturday 12 September 2026',
			'event_time'         => '21:00',
			'doors_open'         => '20:00',
			'venue_name'         => 'The Grand Hall',
			'venue_address'      => '12 Station Road',
			'venue_city'         => 'Your City',
			'organizer_name'     => $organizer ? $organizer->name : get_bloginfo( 'name' ),
			'organizer_phone'    => '+44 7700 900123',
			'organizer_email'    => 'contact@example.com',
			'organizer_whatsapp' => '+44 7700 900123',
			'lineup'             => 'Resident DJ (DJ) · Live band (band)',
			'dress_code'         => 'Smart casual',
			'price'              => BFNET_Util::money( 12000 ),
			'qr'                 => home_url( '/' . BFNET_Settings::get( 'qr_endpoint', 'bfnet-verify' ) . '/' . str_repeat( 'a1b2', 12 ) . '/' ),
			'_organizer'         => $organizer,
		);

		$map = BFNET_Template::scaled_fieldmap( $template );

		foreach ( $map['fields'] as $field ) {
			$box = array( 'x' => (int) $field['x'], 'y' => (int) $field['y'], 'w' => (int) $field['w'], 'h' => (int) $field['h'] );

			if ( 'qr' === $field['type'] ) {
				self::draw_qr( $canvas, $sample['qr'], $box, $field );
			} elseif ( 'image' === $field['type'] ) {
				self::draw_image( $canvas, $sample, $field, $box );
			} else {
				$value = ( 'static' === $field['key'] ) ? ( isset( $field['value'] ) ? $field['value'] : '' ) : ( isset( $sample[ $field['key'] ] ) ? $sample[ $field['key'] ] : '' );
				if ( '' !== trim( (string) $value ) ) {
					BFNET_Canvas::draw_text_block( $canvas, $value, $box, $field );
				}
			}
		}

		// Streamed to a browser, not to a printer: half size is plenty and keeps
		// a 1181 x 2953 proof under a megabyte.
		if ( imagesx( $canvas ) > 900 ) {
			$scale  = 900 / imagesx( $canvas );
			$small  = imagecreatetruecolor( 900, max( 1, (int) round( imagesy( $canvas ) * $scale ) ) );
			imagecopyresampled( $small, $canvas, 0, 0, 0, 0, imagesx( $small ), imagesy( $small ), imagesx( $canvas ), imagesy( $canvas ) );
			imagedestroy( $canvas );
			$canvas = $small;
		}

		ob_start();
		imagejpeg( $canvas, null, 86 );
		$jpeg = ob_get_clean();
		imagedestroy( $canvas );

		return $jpeg;
	}
}
