<?php
/**
 * BitForgeNet Guest Tickets - Fulfilment pipeline.
 *
 * The chain triggered the moment an agent flips an order to "paid":
 * issue one ticket per attendee -> render each ticket PDF -> build the combined
 * PDF -> email everything to the buyer -> stamp the order as delivered.
 *
 * Delivery is deliberately belt-and-braces. Mail servers routinely strip or reject
 * large attachments without telling anyone, so every ticket email also carries a
 * signed download link per guest, and the attachment payload is measured and trimmed
 * before sending rather than after a customer complains.
 *
 * @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


/**
 * Orchestrates ticket issuance and delivery.
 */
class BFNET_Fulfilment {

	/**
	 * Whether anything is stopping this booking's tickets going out.
	 *
	 * Core knows of nothing: a paid booking is a booking whose tickets can be
	 * printed. A module may disagree — a night that seats its guests cannot
	 * print a ticket before it knows which table is on it — and says so by
	 * returning a WP_Error a human can act on.
	 *
	 * @param int         $order_id The booking.
	 * @param object|null $event    Its event.
	 * @return null|WP_Error
	 */
	public static function held( $order_id, $event ) {
		return apply_filters( 'bfnet_fulfilment_held', null, (int) $order_id, $event );
	}

	/**
	 * Run the full pipeline for a newly paid order.
	 *
	 * @param int    $order_id Order id.
	 * @param object $order    Order row.
	 * @return void
	 */
	public static function on_order_paid( $order_id, $order = null ) {
		if ( ! (int) BFNET_Settings::get( 'auto_issue_on_paid', 1 ) ) {
			return;
		}

		$order = $order ? $order : BFNET_Order::get( $order_id );
		$event = $order ? BFNET_Event::get( (int) $order->event_id ) : null;

		/**
		 * Last chance to settle anything before the tickets go out.
		 *
		 * A module that must finish something first — placing a booking at a
		 * table, say — does it here, and then either lets the fulfilment run or
		 * holds it on the filter below.
		 *
		 * @param int         $order_id The booking.
		 * @param object|null $event    Its event.
		 */
		do_action( 'bfnet_before_fulfilment', $order_id, $event );

		$held = self::held( $order_id, $event );

		if ( is_wp_error( $held ) ) {
			// A ticket printed without something it needed has to be reprinted,
			// so the booking is held and the team finishes it by hand.
			BFNET_Log::audit(
				'order',
				$order_id,
				'tickets_held',
				array(
					'reference' => $order ? $order->reference : '',
					'reason'    => $held->get_error_message(),
				),
				'warning'
			);
			return;
		}

		self::fulfil( $order_id );
	}

	/**
	 * Issue, render and email the tickets for an order.
	 *
	 * @param int  $order_id Order id.
	 * @param bool $resend   Re-render and resend even if already delivered.
	 * @return array|WP_Error Summary of what happened.
	 */
	public static function fulfil( $order_id, $resend = false ) {
		global $wpdb;

		$order = BFNET_Order::get( $order_id );
		if ( ! $order ) {
			return new WP_Error( 'bfnet_no_order', __( 'Order not found.', 'bitforgenet-guest-tickets' ) );
		}
		if ( 'paid' !== $order->payment_status ) {
			return new WP_Error( 'bfnet_not_paid', __( 'Only a paid order can be fulfilled.', 'bitforgenet-guest-tickets' ) );
		}

		// The same gate, restated for every other way tickets can be sent —
		// a resend, a bulk validation, a webhook.
		$held = self::held( $order_id, BFNET_Event::get( (int) $order->event_id ) );

		if ( is_wp_error( $held ) ) {
			return $held;
		}

		@set_time_limit( 600 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, Squiz.PHP.DiscouragedFunctions.Discouraged -- archiving and issuing run long on a large event; hosts that forbid this ignore it.

		$issued = BFNET_Ticket::issue_for_order( $order_id );
		if ( is_wp_error( $issued ) ) {
			BFNET_Log::error( 'fulfil_issue_failed', $issued->get_error_message(), array( 'order_id' => $order_id ) );
			return $issued;
		}

		// When re-sending, tickets already exist so issue_for_order returns nothing.
		// Rotate their tokens so the previous PDFs stop working, then rebuild.
		if ( empty( $issued ) && $resend ) {
			$issued = array();
			foreach ( BFNET_Order::tickets( $order_id ) as $ticket ) {
				if ( 'void' === $ticket->status ) {
					continue;
				}
				$token = BFNET_Ticket::rotate_token( (int) $ticket->id );
				if ( is_wp_error( $token ) ) {
					continue;
				}
				$issued[ (int) $ticket->id ] = array(
					'ticket'   => BFNET_Ticket::get( (int) $ticket->id ),
					'attendee' => BFNET_Ticket::attendee( $ticket ),
					'token'    => $token,
				);
			}
		}

		if ( empty( $issued ) ) {
			return new WP_Error( 'bfnet_nothing_to_issue', __( 'Tickets were already issued for this order. Use "Regenerate and resend" to rebuild them.', 'bitforgenet-guest-tickets' ) );
		}

		$errors = array();

		// 1. Always render one PDF per ticket. The file has to exist on disk for the
		//    download link to work, whether or not it ends up attached.
		$individual = array();
		foreach ( $issued as $ticket_id => $entry ) {
			$path = BFNET_Ticket_Renderer::render_pdf( $entry['ticket'], $entry['token'] );
			if ( is_wp_error( $path ) ) {
				$errors[] = $path->get_error_message();
				continue;
			}
			$individual[ $ticket_id ] = $path;
			// Refresh the row so download_url() sees the stored filename.
			$issued[ $ticket_id ]['ticket'] = BFNET_Ticket::get( $ticket_id );
		}

		// 2. Optional combined PDF for a household printing in one go.
		$bundle = null;
		if ( (int) BFNET_Settings::get( 'attach_combined_pdf', 1 ) && count( $issued ) > 1 ) {
			$built = BFNET_Ticket_Renderer::render_bundle( $order_id, $issued );
			if ( is_wp_error( $built ) ) {
				$errors[] = $built->get_error_message();
			} else {
				$bundle = $built;
			}
		}

		// 3. Decide what actually travels by email.
		$plan        = self::plan_attachments( $individual, $bundle, count( $issued ) );
		$attachments = $plan['attachments'];

		if ( $plan['trimmed'] ) {
			$errors[] = sprintf(
				/* translators: 1: payload size in MB, 2: configured limit in MB. */
				__( 'Attachments trimmed: %1$s MB exceeded the %2$s MB limit. Download links were sent instead.', 'bitforgenet-guest-tickets' ),
				number_format( $plan['raw_bytes'] / 1048576, 2 ),
				number_format( $plan['limit_bytes'] / 1048576, 1 )
			);
		}

		// 4. Download links always go out, so a stripped attachment is never fatal.
		$links = self::download_links( $issued, $bundle );

		$sent = BFNET_Mailer::send_tickets( $order_id, $attachments, $issued, $links );

		if ( $sent ) {
			$wpdb->update(
				BFNET_Order::table(),
				array( 'tickets_sent_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' ) ),
				array( 'id' => (int) $order_id ),
				array( '%s', '%s' ),
				array( '%d' )
			);
		}

		BFNET_Log::audit(
			'order',
			$order_id,
			'order_fulfilled',
			array(
				'tickets'          => count( $issued ),
				'pdfs_rendered'    => count( $individual ),
				'attachments_sent' => count( $attachments ),
				'attachment_bytes' => $plan['sent_bytes'],
				'payload_trimmed'  => $plan['trimmed'],
				'email_sent'       => (bool) $sent,
				'errors'           => $errors,
			),
			empty( $errors ) ? 'info' : 'warning'
		);

		BFNET_Order::refresh_event_counters( (int) $order->event_id );

		return array(
			'tickets'     => count( $issued ),
			'attachments' => count( $attachments ),
			'bytes'       => $plan['sent_bytes'],
			'trimmed'     => $plan['trimmed'],
			'email_sent'  => (bool) $sent,
			'errors'      => $errors,
		);
	}

	/**
	 * Choose which files to attach, staying under the configured size ceiling.
	 *
	 * Degrades in a deliberate order: everything, then the combined PDF alone, then
	 * nothing but links. A guest is never left without a route to their ticket.
	 *
	 * @param array       $individual Map of ticket_id => path.
	 * @param string|null $bundle     Combined PDF path.
	 * @param int         $count      Number of tickets.
	 * @return array
	 */
	private static function plan_attachments( array $individual, $bundle, $count ) {
		$limit = max( 1, (int) BFNET_Settings::get( 'email_max_attachment_mb', 4 ) ) * 1048576;

		$want_individual = (int) BFNET_Settings::get( 'attach_individual_pdfs', 0 ) || 1 === $count;

		$candidates = array();
		if ( $bundle ) {
			$candidates[] = $bundle;
		}
		if ( $want_individual ) {
			foreach ( $individual as $path ) {
				$candidates[] = $path;
			}
		}

		// A single-ticket order with no bundle still needs its one file.
		if ( empty( $candidates ) && $individual ) {
			$candidates[] = reset( $individual );
		}

		$raw = 0;
		foreach ( $candidates as $path ) {
			$raw += is_readable( $path ) ? (int) filesize( $path ) : 0;
		}

		if ( $raw <= $limit ) {
			return array(
				'attachments' => $candidates,
				'raw_bytes'   => $raw,
				'sent_bytes'  => $raw,
				'limit_bytes' => $limit,
				'trimmed'     => false,
			);
		}

		// Too big: fall back to the combined file on its own.
		if ( $bundle && is_readable( $bundle ) && filesize( $bundle ) <= $limit ) {
			return array(
				'attachments' => array( $bundle ),
				'raw_bytes'   => $raw,
				'sent_bytes'  => (int) filesize( $bundle ),
				'limit_bytes' => $limit,
				'trimmed'     => true,
			);
		}

		// Still too big: links only.
		return array(
			'attachments' => array(),
			'raw_bytes'   => $raw,
			'sent_bytes'  => 0,
			'limit_bytes' => $limit,
			'trimmed'     => true,
		);
	}

	/**
	 * Build the signed per-guest download links included in the email.
	 *
	 * @param array       $issued Issued ticket map.
	 * @param string|null $bundle Combined PDF path (unused for now, reserved).
	 * @return array List of ['name', 'serial', 'url'].
	 */
	private static function download_links( array $issued, $bundle = null ) {
		unset( $bundle );

		if ( ! (int) BFNET_Settings::get( 'email_download_links', 1 ) ) {
			return array();
		}

		$days  = max( 1, (int) BFNET_Settings::get( 'download_link_days', 30 ) );
		$links = array();

		foreach ( $issued as $entry ) {
			$ticket = $entry['ticket'];
			if ( ! $ticket || empty( $ticket->pdf_file ) ) {
				continue;
			}

			$attendee = isset( $entry['attendee'] ) ? $entry['attendee'] : BFNET_Ticket::attendee( $ticket );

			$links[] = array(
				'name'   => $attendee ? BFNET_Util::full_name( $attendee->first_name, $attendee->last_name ) : '',
				'serial' => $ticket->serial,
				'url'    => BFNET_Ticket::download_url( $ticket, $days * DAY_IN_SECONDS ),
			);
		}

		return $links;
	}

	/**
	 * Rebuild a single ticket's PDF with a fresh token and email it on its own.
	 *
	 * @param int $ticket_id Ticket id.
	 * @return true|WP_Error
	 */
	public static function resend_ticket( $ticket_id ) {
		$ticket = BFNET_Ticket::get( $ticket_id );
		if ( ! $ticket ) {
			return new WP_Error( 'bfnet_no_ticket', __( 'Ticket not found.', 'bitforgenet-guest-tickets' ) );
		}

		$order = BFNET_Order::get( $ticket->order_id );
		if ( ! $order || 'paid' !== $order->payment_status ) {
			return new WP_Error( 'bfnet_not_paid', __( 'The order for this ticket is not paid.', 'bitforgenet-guest-tickets' ) );
		}

		$token = BFNET_Ticket::rotate_token( $ticket_id );
		if ( is_wp_error( $token ) ) {
			return $token;
		}

		BFNET_Ticket::delete_pdf( $ticket );

		$ticket = BFNET_Ticket::get( $ticket_id );
		$path   = BFNET_Ticket_Renderer::render_pdf( $ticket, $token );
		if ( is_wp_error( $path ) ) {
			return $path;
		}

		$ticket   = BFNET_Ticket::get( $ticket_id );
		$attendee = BFNET_Ticket::attendee( $ticket );
		$issued   = array( (int) $ticket->id => array( 'ticket' => $ticket, 'attendee' => $attendee, 'token' => $token ) );

		BFNET_Mailer::send_tickets( (int) $order->id, array( $path ), $issued, self::download_links( $issued ) );

		BFNET_Log::audit( 'ticket', $ticket_id, 'ticket_resent', array( 'serial' => $ticket->serial ) );

		return true;
	}

	/* --------------------------------------------------------------------- */
	/* Diagnostics                                                           */
	/* --------------------------------------------------------------------- */

	/**
	 * Run the checks that explain why tickets or emails are not arriving.
	 *
	 * @return array List of ['label', 'ok', 'detail'].
	 */
	public static function diagnostics() {
		$checks = array();

		// GD and FreeType.
		$gd_ok   = extension_loaded( 'gd' ) && function_exists( 'imagettftext' );
		$gd_info = function_exists( 'gd_info' ) ? gd_info() : array();
		$checks[] = array(
			'label'  => __( 'PHP GD with FreeType', 'bitforgenet-guest-tickets' ),
			'ok'     => $gd_ok,
			'detail' => $gd_ok
				? sprintf( 'GD %s', isset( $gd_info['GD Version'] ) ? $gd_info['GD Version'] : '' )
				: __( 'Missing. Tickets cannot be drawn. Ask your host to enable GD with FreeType support.', 'bitforgenet-guest-tickets' ),
		);

		// Storage.
		$storage  = BFNET_Install::storage();
		$dir      = $storage['dir'] . '/tickets';
		$writable = is_dir( $dir ) && wp_is_writable( $dir );
		$checks[] = array(
			'label'  => __( 'Ticket storage writable', 'bitforgenet-guest-tickets' ),
			'ok'     => $writable,
			'detail' => $writable ? $dir : sprintf( /* translators: %s: folder path. */ __( 'Cannot write to %s', 'bitforgenet-guest-tickets' ), $dir ),
		);

		// A real render, end to end.
		$template = BFNET_Template::get_default();
		$render   = false;
		$detail   = __( 'No ticket design is available.', 'bitforgenet-guest-tickets' );

		if ( $template ) {
			$sample = BFNET_Ticket_Renderer::render_sample( $template );
			if ( ! is_wp_error( $sample ) && strlen( $sample ) > 1000 ) {
				$render = true;
				$detail = sprintf(
					/* translators: 1: design name, 2: rendered size in KB. */
					__( '%1$s rendered, %2$s KB per page', 'bitforgenet-guest-tickets' ),
					$template->name,
					number_format( strlen( $sample ) / 1024, 0 )
				);
			} else {
				$detail = is_wp_error( $sample ) ? $sample->get_error_message() : __( 'The renderer produced an empty image.', 'bitforgenet-guest-tickets' );
			}
		}

		$checks[] = array(
			'label'  => __( 'Ticket rendering', 'bitforgenet-guest-tickets' ),
			'ok'     => $render,
			'detail' => $detail,
		);

		// Attachment budget.
		$limit    = max( 1, (int) BFNET_Settings::get( 'email_max_attachment_mb', 4 ) );
		$checks[] = array(
			'label'  => __( 'Email attachment budget', 'bitforgenet-guest-tickets' ),
			'ok'     => true,
			'detail' => sprintf(
				/* translators: 1: limit in MB, 2: JPEG quality. */
				__( 'Ceiling %1$d MB · PDF quality %2$d. Oversized payloads fall back to download links automatically.', 'bitforgenet-guest-tickets' ),
				$limit,
				BFNET_Ticket_Renderer::jpeg_quality()
			),
		);

		// Recent email failures.
		global $wpdb;
		$emails = BFNET_Install::table( 'emails' );
		$failed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$emails} WHERE status = 'failed'" ); // phpcs:ignore WordPress.DB.PreparedSQL
		$total  = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$emails}" ); // phpcs:ignore WordPress.DB.PreparedSQL

		$checks[] = array(
			'label'  => __( 'Email delivery log', 'bitforgenet-guest-tickets' ),
			'ok'     => 0 === $failed,
			'detail' => $total
				? sprintf(
					/* translators: 1: number of failures, 2: total messages. */
					__( '%1$d failed out of %2$d sent', 'bitforgenet-guest-tickets' ),
					$failed,
					$total
				)
				: __( 'No email sent yet.', 'bitforgenet-guest-tickets' ),
		);

		// Booking page.
		$page_url = BFNET_Util::registration_page_url();
		$checks[] = array(
			'label'  => __( 'Booking page detected', 'bitforgenet-guest-tickets' ),
			'ok'     => (bool) BFNET_Util::registration_page_id(),
			'detail' => BFNET_Util::registration_page_id()
				? $page_url
				: __( 'No page contains [bitforgenet_registration]. Direct event links will not work until one does.', 'bitforgenet-guest-tickets' ),
		);

		// The door: a camera needs HTTPS, and the QR needs a route that resolves.
		$https = is_ssl() || 0 === strpos( strtolower( (string) home_url() ), 'https://' );
		$checks[] = array(
			'label'  => __( 'Site served over HTTPS', 'bitforgenet-guest-tickets' ),
			'ok'     => $https,
			'detail' => $https
				? __( 'Cameras are allowed to open on this site.', 'bitforgenet-guest-tickets' )
				: __( 'No browser will open a camera on a plain http:// page. Install a certificate, or use “Take a photo” and manual entry at the door.', 'bitforgenet-guest-tickets' ),
		);

		$verify = BFNET_Ticket::verify_url( 'BFNETDIAGNOSTICSAMPLETOKEN000000000' );
		$pretty = (bool) get_option( 'permalink_structure' );
		// The certificate is verified like any other request. Turning that off
		// would hide exactly the class of problem this check exists to find: a
		// phone camera will not open a page whose certificate does not check out
		// either, so a diagnostic that ignores it reports a door that works when
		// it does not.
		$probe = wp_remote_get(
			$verify,
			array(
				'timeout' => 8,
				'headers' => array( 'Accept' => 'text/html' ),
			)
		);
		$code       = is_wp_error( $probe ) ? 0 : (int) wp_remote_retrieve_response_code( $probe );
		$body       = is_wp_error( $probe ) ? '' : (string) wp_remote_retrieve_body( $probe );
		$route_ok   = ( 200 === $code || 404 === $code ) && false !== strpos( $body, 'bfnet-verify-page' );
		$checks[]   = array(
			'label'  => __( 'Ticket QR route', 'bitforgenet-guest-tickets' ),
			'ok'     => $route_ok,
			'detail' => $route_ok
				? sprintf(
					/* translators: %s: verification URL. */
					__( 'A phone camera pointed at a ticket lands on %s and gets a verdict.', 'bitforgenet-guest-tickets' ),
					$verify
				)
				: sprintf(
					/* translators: 1: verification URL, 2: HTTP status. */
					__( '%1$s did not answer with the verification page (HTTP %2$d). Open Settings → Permalinks and press Save once, then open that link in a phone browser to confirm — some hosts block a site from calling itself, or present a certificate only outsiders can verify, either of which fails this test without breaking the door. %3$s', 'bitforgenet-guest-tickets' ),
					$verify,
					$code,
					$pretty ? '' : __( 'Permalinks are set to Plain, so the plugin is using the ?bfnet_token= form.', 'bitforgenet-guest-tickets' )
				),
		);

		// The door station itself.
		$station = BFNET_Door::url();
		$hit     = wp_remote_get( $station, array( 'timeout' => 8 ) );
		$open    = ! is_wp_error( $hit ) && 200 === (int) wp_remote_retrieve_response_code( $hit )
			&& false !== strpos( (string) wp_remote_retrieve_body( $hit ), 'bfnet_action' );

		$checks[] = array(
			'label'  => __( 'Door station reachable', 'bitforgenet-guest-tickets' ),
			'ok'     => $open,
			'detail' => $open
				? sprintf(
					/* translators: %s: station URL. */
					__( 'Agents can open %s and enter their code.', 'bitforgenet-guest-tickets' ),
					$station
				)
				: sprintf(
					/* translators: %s: station URL. */
					__( '%s did not answer with the code screen. Open Settings → Permalinks and press Save once, then try the link in a phone browser — some hosts block a site from calling itself, which fails this test without breaking the door.', 'bitforgenet-guest-tickets' ),
					$station
				),
		);

		return $checks;
	}

	/**
	 * Send a test email, optionally with a rendered sample ticket attached.
	 *
	 * @param string $to Recipient address.
	 * @return true|WP_Error
	 */
	public static function send_test_email( $to ) {
		$to = sanitize_email( $to );
		if ( ! $to || ! is_email( $to ) ) {
			return new WP_Error( 'bfnet_bad_email', __( 'That email address is not valid.', 'bitforgenet-guest-tickets' ) );
		}

		$attachments = array();
		$template    = BFNET_Template::get_default();
		$note        = __( 'No ticket design was available, so this test carries no attachment.', 'bitforgenet-guest-tickets' );

		if ( $template ) {
			$sample = BFNET_Ticket_Renderer::render_sample( $template );
			if ( ! is_wp_error( $sample ) ) {
				$storage = BFNET_Install::storage();
				$path    = $storage['dir'] . '/tmp/bfnet-test-ticket.pdf';

				list( $mm_w, $mm_h ) = BFNET_Ticket_Renderer::page_mm( $template );

				$pdf = new BFNET_PDF();
				$pdf->set_meta( array( 'title' => 'BitForgeNet Guest Tickets test ticket' ) );
				$pdf->add_jpeg_page( $sample, (int) $template->width_px, (int) $template->height_px, $mm_w, $mm_h );

				if ( $pdf->save( $path ) ) {
					$attachments[] = $path;
					$note          = sprintf(
						/* translators: %s: attachment size in KB. */
						__( 'A sample ticket of %s KB is attached. If you can see it, attachments work on this server.', 'bitforgenet-guest-tickets' ),
						number_format( filesize( $path ) / 1024, 0 )
					);
				}
			}
		}

		$body = '<h1 style="margin:0 0 16px;font-size:22px;color:#111">' . esc_html__( 'BitForgeNet Guest Tickets test email', 'bitforgenet-guest-tickets' ) . '</h1>'
			. '<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#3A3A3A">' . esc_html__( 'If this arrived, your site can send BitForgeNet Guest Tickets email.', 'bitforgenet-guest-tickets' ) . '</p>'
			. '<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#3A3A3A">' . esc_html( $note ) . '</p>';

		$sent = BFNET_Mailer::send(
			$to,
			__( 'BitForgeNet Guest Tickets — test email', 'bitforgenet-guest-tickets' ),
			BFNET_Mailer::wrap( __( 'BitForgeNet Guest Tickets test email', 'bitforgenet-guest-tickets' ), $body, BFNET_Organizer::primary() ),
			$attachments,
			0,
			0,
			0,
			'diagnostic_test'
		);

		if ( ! $sent ) {
			return new WP_Error( 'bfnet_mail_failed', __( 'WordPress could not send the message. Check the delivery log and your SMTP configuration.', 'bitforgenet-guest-tickets' ) );
		}

		return true;
	}
}
