<?php
/**
 * BitForgeNet Guest Tickets - Admin controller: menus, assets and form handling.
 *
 * @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

// Every handler in this file is reached through route_actions(), which calls
// BFNET_Security::verify_admin_request() -- nonce and capability -- before it
// dispatches. A handler that reads $_REQUEST is therefore reading a request
// that has already been proved, and repeating the check inside each of the
// twenty-odd handlers would state the same fact twenty times.
//
// The reads that do not come through the router are the screen renderers and
// the two file servers, which change nothing and open with
// BFNET_Security::require_cap(); and the first line of the router itself, which
// reads the action name in order to look it up in a fixed list, before there
// is anything yet to verify against.
//
// Said once here, and deliberately never re-enabled: a scoped block closed
// with an enable directive would switch this back on for everything below it.
// phpcs:disable WordPress.Security.NonceVerification


/**
 * Wires up every wp-admin screen the plugin owns.
 */
class BFNET_Admin {

	/**
	 * Hook suffixes of the plugin's screens.
	 *
	 * @var array
	 */
	private $screens = array();

	/**
	 * Hook up.
	 */
	public function __construct() {
		add_action( 'admin_menu', array( $this, 'register_menu' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
		add_action( 'admin_init', array( $this, 'route_actions' ) );
		add_action( 'admin_init', array( $this, 'serve_template_preview' ) );
		add_action( 'admin_init', array( $this, 'allow_camera' ), 1 );
		add_action( 'wp_ajax_bfnet_save_event', array( $this, 'ajax_save_event' ) );
		add_filter( 'plugin_action_links_' . BFNET_BASENAME, array( $this, 'action_links' ) );
		add_action( 'admin_notices', array( $this, 'render_notices' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'menu_icon_states' ) );
	}

	/* --------------------------------------------------------------------- */
	/* Menu                                                                  */
	/* --------------------------------------------------------------------- */

	/**
	 * Monochrome SVG mark used as the menu icon.
	 *
	 * @return string Data URI.
	 */
	/**
	 * The crest, as one outline: shield ring, then J and F as holes inside it.
	 *
	 * @var string
	 */
	const MARK_PATH = 'M568.1,223.2l7.9-11.9-4,13.7c-5.5,19.4-12,39.7-17.1,58.9h35.5c-15.5,20.6-31.3,41-47.4,61.2-1.7,2.1-4.3,5.5-6.1,7.3h-.6c.7-3.1,1.8-6.6,2.6-9.8,3.4-12.6,7-25.1,10.7-37.5h-32.3c16.3-27.7,33.2-55,50.8-81.8Z';

	private function menu_icon() {
		return 'data:image/svg+xml;base64,' . base64_encode( self::mark( '#a7aaad' ) );
	}

	/**
	 * The BitForgeNet mark, in one colour.
	 *
	 * The admin menu takes an image, not a stylesheet, so the icon cannot inherit
	 * a colour the way an inline SVG would — it has to be drawn in the colour it
	 * is wanted in. This returns the same crest at whatever colour is asked for,
	 * which is how the sidebar icon lights up gold on the screen you are on.
	 *
	 * @param string $colour Hex colour for the shield.
	 * @return string SVG source.
	 */
	public static function mark( $colour ) {
		// One path, one colour, and no text: letters drawn as <text> inside an SVG
		// used as a background image depend on a font the renderer may decline to
		// load, which is how a mark comes and goes between one menu state and the
		// next. These are outlines, so they cannot fail to appear. The mark reads
		// at twenty pixels as well as at two hundred, on a light admin colour
		// scheme as well as a dark one.
		return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="515 209 78 146">'
			. '<path fill="' . $colour . '" fill-rule="evenodd" d="' . self::MARK_PATH . '"/>'
			. '</svg>';
	}

	/**
	 * Light the sidebar icon up on the screen you are on.
	 *
	 * Printed on every admin page rather than only ours, because the menu is on
	 * every admin page. It is a few hundred bytes and it saves the plugin from
	 * being the one grey mark in a menu that highlights everything else.
	 *
	 * @return void
	 */
	public function menu_icon_states() {
		$gold  = 'data:image/svg+xml;base64,' . base64_encode( self::mark( '#d8b53f' ) );
		$white = 'data:image/svg+xml;base64,' . base64_encode( self::mark( '#ffffff' ) );

		// A stylesheet with no file behind it. The rule is built out of the mark
		// itself, so there is nothing to put in a .css; registering a handle with
		// no source is how WordPress carries CSS that only exists at runtime, and
		// it puts this through the same queue as everything else instead of
		// printing a <style> into the head by hand. The menu is on every admin
		// screen, so this hangs on admin_enqueue_scripts rather than on the
		// plugin's own enqueue, which returns early elsewhere.
		wp_register_style( 'bitforgenet-guest-tickets-menu-icon', false, array(), BFNET_VERSION );
		wp_enqueue_style( 'bitforgenet-guest-tickets-menu-icon' );

		// The two URLs are data: — WordPress does not allow that scheme in a URL
		// and esc_url() would strip them to nothing, which is how the highlight
		// silently stopped working once before. Nothing here comes from a request:
		// the whole string is base64 of our own markup, and that alphabet cannot
		// close the quote or the parenthesis.
		wp_add_inline_style(
			'bitforgenet-guest-tickets-menu-icon',
			'#toplevel_page_bitforgenet-guest-tickets .wp-menu-image{background-size:20px auto!important;}'
			. '#toplevel_page_bitforgenet-guest-tickets:hover .wp-menu-image,'
			. '#toplevel_page_bitforgenet-guest-tickets.wp-has-current-submenu .wp-menu-image'
			. '{background-image:url("' . $gold . '")!important;}'
			. '#toplevel_page_bitforgenet-guest-tickets.current .wp-menu-image'
			. '{background-image:url("' . $white . '")!important;}'
		);
	}

	/**
	 * Register the top-level menu and its sub-pages.
	 *
	 * @return void
	 */
	public function register_menu() {
		$cap = BFNET_Security::can( 'bfnet_manage_events' ) ? 'read' : 'read';

		$this->screens['dashboard'] = add_menu_page(
			__( 'BitForgeNet Guest Tickets', 'bitforgenet-guest-tickets' ),
			__( 'BitForgeNet', 'bitforgenet-guest-tickets' ),
			$cap,
			'bitforgenet-guest-tickets',
			array( $this, 'render_dashboard' ),
			$this->menu_icon(),
			26
		);

		$pages = array(
			array( 'bitforgenet-guest-tickets', __( 'Dashboard', 'bitforgenet-guest-tickets' ), 'bfnet_view_reports', 'render_dashboard' ),
			array( 'bitforgenet-guest-tickets-events', __( 'Events', 'bitforgenet-guest-tickets' ), 'bfnet_manage_events', 'render_events' ),
			array( 'bitforgenet-guest-tickets-orders', __( 'Subscribers', 'bitforgenet-guest-tickets' ), 'bfnet_manage_orders', 'render_orders' ),
			array( 'bitforgenet-guest-tickets-tickets', __( 'Tickets', 'bitforgenet-guest-tickets' ), 'bfnet_manage_tickets', 'render_tickets' ),
			array( 'bitforgenet-guest-tickets-scanner', __( 'Door scanner', 'bitforgenet-guest-tickets' ), 'bfnet_scan_tickets', 'render_scanner' ),
			array( 'bitforgenet-guest-tickets-templates', __( 'Ticket designs', 'bitforgenet-guest-tickets' ), 'bfnet_manage_templates', 'render_templates' ),
			array( 'bitforgenet-guest-tickets-organizers', __( 'Organizers', 'bitforgenet-guest-tickets' ), 'bfnet_manage_events', 'render_organizers' ),
			array( 'bitforgenet-guest-tickets-venues', __( 'Venues', 'bitforgenet-guest-tickets' ), 'bfnet_manage_events', 'render_venues' ),
			array( 'bitforgenet-guest-tickets-settings', __( 'Settings', 'bitforgenet-guest-tickets' ), 'bfnet_manage_settings', 'render_settings' ),
			array( 'bitforgenet-guest-tickets-tools', __( 'Tools & data', 'bitforgenet-guest-tickets' ), 'bfnet_manage_settings', 'render_tools' ),
			array( 'bitforgenet-guest-tickets-logs', __( 'Activity log', 'bitforgenet-guest-tickets' ), 'bfnet_manage_settings', 'render_logs' ),
		);

		foreach ( $pages as $page ) {
			list( $slug, $label, $capability, $callback ) = $page;

			if ( ! BFNET_Security::can( $capability ) ) {
				continue;
			}

			$this->screens[ $slug ] = add_submenu_page(
				'bitforgenet-guest-tickets',
				$label . ' — ' . __( 'BitForgeNet', 'bitforgenet-guest-tickets' ),
				$label,
				'read',
				$slug,
				array( $this, $callback )
			);
		}
	}

	/**
	 * Quick links on the Plugins screen.
	 *
	 * @param array $links Existing links.
	 * @return array
	 */
	public function action_links( $links ) {
		array_unshift(
			$links,
			'<a href="' . esc_url( BFNET_Util::admin_url( 'bitforgenet-guest-tickets' ) ) . '">' . esc_html__( 'Dashboard', 'bitforgenet-guest-tickets' ) . '</a>',
			'<a href="' . esc_url( BFNET_Util::admin_url( 'bitforgenet-guest-tickets-settings' ) ) . '">' . esc_html__( 'Settings', 'bitforgenet-guest-tickets' ) . '</a>'
		);
		return $links;
	}

	/**
	 * Load admin CSS/JS only on plugin screens.
	 *
	 * @param string $hook Current screen hook.
	 * @return void
	 */
	/**
	 * Let the door scanner reach the camera.
	 *
	 * Several hosts, CDNs and security plugins send a blanket
	 * `Permissions-Policy: camera=()`, which makes Chrome refuse getUserMedia with
	 * no visible reason at all — the preview simply stays black. On the scanner
	 * screen only, we re-state the policy so this document may use its own camera.
	 */
	/**
	 * Save the event editor without reloading the page.
	 *
	 * There are enough settings on that screen that a full round trip for every
	 * small change is its own discouragement — and a reload after a failed write
	 * looks identical to a reload after a successful one, which is how a form can
	 * appear to save and not save at the same time. This answers with the truth.
	 *
	 * @return void
	 */
	public function ajax_save_event() {
		$nonce = isset( $_POST['_bfnet_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_bfnet_nonce'] ) ) : '';

		if ( ! wp_verify_nonce( $nonce, 'bfnet_save_event' ) || ! BFNET_Security::can( 'bfnet_manage_events' ) ) {
			wp_send_json(
				array(
					'ok'      => false,
					'message' => __( 'Your session expired. Reload the page and try again.', 'bitforgenet-guest-tickets' ),
				)
			);
		}

		// The schema has to be in shape before a save, or new fields vanish
		// silently — which is exactly the failure this replaces.
		BFNET_Install::maybe_upgrade();

		$data   = $this->event_payload();
		$result = BFNET_Event::save( $data );

		if ( is_wp_error( $result ) ) {
			wp_send_json( array( 'ok' => false, 'message' => $result->get_error_message() ) );
		}

		/**
		 * An event has just been written.
		 *
		 * A module that keeps its own rows alongside an event — price tiers, a
		 * seating plan — syncs them here. The admin object comes along so a
		 * module can read the fields its own tab posted.
		 *
		 * @param int       $event_id The event.
		 * @param array     $data     The payload that was saved.
		 * @param BFNET_Admin $admin    The admin screen doing the saving.
		 */
		do_action( 'bfnet_event_saved', (int) $result, $data, $this );

		$event   = BFNET_Event::get( (int) $result );
		$missing = array();

		// Report anything the table could not store, rather than pretending.
		foreach ( array( 'tables_mode', 'table_seats', 'menu_enabled', 'sponsors_image' ) as $field ) {
			if ( $event && ! property_exists( $event, $field ) ) {
				$missing[] = $field;
			}
		}

		wp_send_json(
			array(
				'ok'      => true,
				'id'      => (int) $result,
				'message' => $missing
					? sprintf(
						/* translators: %s: list of field names. */
						__( 'Saved, but your database is missing %s. Open BitForgeNet Guest Tickets → Tools and run the repair.', 'bitforgenet-guest-tickets' ),
						implode( ', ', $missing )
					)
					: __( 'Saved', 'bitforgenet-guest-tickets' ),
			)
		);
	}

	public function allow_camera() {
		if ( headers_sent() ) {
			return;
		}
		$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
		if ( 'bitforgenet-guest-tickets-scanner' !== $page ) {
			return;
		}
		header( 'Permissions-Policy: camera=(self), microphone=()' );
		header( 'Feature-Policy: camera \'self\'' );
	}

	public function enqueue( $hook ) {
		if ( ! in_array( $hook, $this->screens, true ) ) {
			return;
		}

		wp_enqueue_style( 'bitforgenet-guest-tickets-admin', BFNET_URL . 'admin/assets/admin.css', array(), BFNET_Util::asset_version( 'admin/assets/admin.css' ) );
		// The bundled QR decoder keeps the door scanner working on browsers without
		// a native BarcodeDetector — desktop Chrome on Windows, Firefox, older Safari.
		wp_enqueue_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_enqueue_script( 'bitforgenet-guest-tickets-admin', BFNET_URL . 'admin/assets/admin.js', array( 'bitforgenet-guest-tickets-qr-decode' ), BFNET_Util::asset_version( 'admin/assets/admin.js' ), true );

		wp_enqueue_media();

		wp_localize_script(
			'bitforgenet-guest-tickets-admin',
			'BFNetAdmin',
			array(
				'restUrl'  => esc_url_raw( rest_url( BFNET_Rest::NAMESPACE_V1 ) ),
				'ajaxUrl'  => admin_url( 'admin-ajax.php' ),
				'eventUrl' => BFNET_Util::admin_url( 'bitforgenet-guest-tickets-events', array( 'action' => 'edit', 'event' => '' ) ),
				'nonce'    => wp_create_nonce( 'wp_rest' ),
				'logoUrl'  => BFNET_URL . 'assets/images/bitforgenet-guest-tickets-logo.png',
				'i18n'     => array(
					'confirmDelete'  => __( 'This cannot be undone. Continue?', 'bitforgenet-guest-tickets' ),
					'cameraDenied'   => __( 'Camera access was refused. Use the manual code box below.', 'bitforgenet-guest-tickets' ),
					'noDetector'     => __( 'This browser cannot scan with the camera. Use Chrome on Android, or type the code manually.', 'bitforgenet-guest-tickets' ),
					'scanning'       => __( 'Point the camera at the ticket QR code…', 'bitforgenet-guest-tickets' ),
					'networkError'   => __( 'Network problem. The scan was not recorded — try again.', 'bitforgenet-guest-tickets' ),
					'chooseImage'    => __( 'Choose an image', 'bitforgenet-guest-tickets' ),
					'useImage'       => __( 'Use this image', 'bitforgenet-guest-tickets' ),
					'unsaved'        => __( 'Unsaved changes', 'bitforgenet-guest-tickets' ),
					'saving'         => __( 'Saving…', 'bitforgenet-guest-tickets' ),
					'copied'         => __( 'Copied', 'bitforgenet-guest-tickets' ),
					'copyManually'   => __( 'Copy this link:', 'bitforgenet-guest-tickets' ),
					'mapFromCoords'  => __( 'built from the coordinates', 'bitforgenet-guest-tickets' ),
					'mapFromAddress' => __( 'built from the address', 'bitforgenet-guest-tickets' ),
				),
			)
		);
	}

	/* --------------------------------------------------------------------- */
	/* Notices                                                               */
	/* --------------------------------------------------------------------- */

	/**
	 * Queue an admin notice for the next page load.
	 *
	 * @param string $message Message text.
	 * @param string $type    success|error|warning|info.
	 * @return void
	 */
	public static function notice( $message, $type = 'success' ) {
		$notices   = get_transient( 'bfnet_notices_' . get_current_user_id() );
		$notices   = is_array( $notices ) ? $notices : array();
		$notices[] = array( 'message' => $message, 'type' => $type );

		set_transient( 'bfnet_notices_' . get_current_user_id(), $notices, 60 );
	}

	/**
	 * Print and clear queued notices.
	 *
	 * @return void
	 */
	public function render_notices() {
		$key     = 'bfnet_notices_' . get_current_user_id();
		$notices = get_transient( $key );

		if ( ! is_array( $notices ) || empty( $notices ) ) {
			return;
		}

		delete_transient( $key );

		foreach ( $notices as $notice ) {
			printf(
				'<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
				esc_attr( $notice['type'] ),
				esc_html( $notice['message'] )
			);
		}
	}

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

	/**
	 * Include an admin view.
	 *
	 * @param string $name View file name.
	 * @param array  $data Variables for the view.
	 * @return void
	 */
	private function view( $name, array $data = array() ) {
		$file = BFNET_PATH . 'admin/views/' . sanitize_file_name( $name ) . '.php';
		if ( ! is_readable( $file ) ) {
			echo '<div class="wrap"><p>' . esc_html__( 'View not found.', 'bitforgenet-guest-tickets' ) . '</p></div>';
			return;
		}

		// A view reads $event and $order, not $data['event'] and $data['order'],
		// because a template littered with array lookups is harder to read than the
		// screen it draws. The array never contains request data: every caller in
		// this class builds it from rows it has already fetched, and EXTR_SKIP means
		// nothing here can overwrite a variable that already exists.
		extract( $data, EXTR_SKIP ); // phpcs:ignore WordPress.PHP.DontExtract
		include $file;
	}

	/**
	 * The requested sub-action.
	 *
	 * @return string
	 */
	private function action() {
		return isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : '';
	}

	/**
	 * Global dashboard.
	 *
	 * @return void
	 */
	public function render_dashboard() {
		BFNET_Security::require_cap( 'bfnet_view_reports' );
		$this->view( 'dashboard' );
	}

	/**
	 * Events list / editor / per-event dashboard.
	 *
	 * @return void
	 */
	public function render_events() {
		BFNET_Security::require_cap( 'bfnet_manage_events' );

		$event_id = isset( $_GET['event'] ) ? (int) $_GET['event'] : 0;

		switch ( $this->action() ) {
			case 'new':
				// The wizard for a new event, the full editor for an existing one.
				// A draft half-built by the wizard still comes back to the wizard
				// until it reaches the end, which is what makes leaving safe.
				$step  = isset( $_GET['step'] ) ? (int) $_GET['step'] : 1;
				$draft = $event_id ? BFNET_Event::get( $event_id ) : null;

				// A bookmarked step for a draft somebody has since deleted must
				// not land on a summary of nothing. Start again instead.
				if ( ! $draft ) {
					$step = 1;
				}

				$this->view( 'event-wizard', array( 'event' => $draft, 'step' => $step ) );
				break;

			case 'edit':
				$event = $event_id ? BFNET_Event::get( $event_id ) : null;
				if ( ! $event ) {
					// Nothing to edit — offer to create instead of showing an
					// editor with nothing in it.
					$this->view( 'events-list' );
					break;
				}
				$this->view( 'event-edit', array( 'event' => $event ) );
				break;

			case 'dashboard':
				$event = BFNET_Event::get( $event_id );
				if ( ! $event ) {
					$this->view( 'events-list' );
					return;
				}
				$this->view( 'event-dashboard', array( 'event' => $event ) );
				break;

			default:
				/**
				 * Screens a module adds under Events.
				 *
				 * Keyed by the action in the URL, valued by the view to render.
				 * A module's screen is one this class has never heard of, which
				 * is what lets the module be absent without leaving a dead menu
				 * item behind.
				 *
				 * @param array $screens action => view name.
				 */
				$screens = apply_filters( 'bfnet_event_screens', array() );
				$action  = $this->action();

				if ( isset( $screens[ $action ] ) ) {
					$event = $event_id ? BFNET_Event::get( $event_id ) : null;
					if ( $event ) {
						$this->view( $screens[ $action ], array( 'event' => $event ) );
						break;
					}
				}

				$this->view( 'events-list' );
				break;
		}
	}

	/**
	 * Orders (subscribers) list and detail.
	 *
	 * @return void
	 */
	public function render_orders() {
		BFNET_Security::require_cap( 'bfnet_manage_orders' );

		$order_id = isset( $_GET['order'] ) ? (int) $_GET['order'] : 0;

		if ( 'export' === $this->action() ) {
			BFNET_Security::require_cap( 'bfnet_view_reports' );
			$this->view( 'export' );
			return;
		}

		if ( 'view' === $this->action() && $order_id ) {
			$order = BFNET_Order::get( $order_id );
			if ( $order ) {
				$this->view( 'order-view', array( 'order' => $order ) );
				return;
			}
		}

		$this->view( 'orders-list' );
	}

	/**
	 * Tickets list.
	 *
	 * @return void
	 */
	public function render_tickets() {
		BFNET_Security::require_cap( 'bfnet_manage_tickets' );
		$this->view( 'tickets-list' );
	}

	/**
	 * Door scanner.
	 *
	 * @return void
	 */
	public function render_scanner() {
		BFNET_Security::require_cap( 'bfnet_scan_tickets' );
		$this->view( 'scanner' );
	}

	/**
	 * Ticket designs.
	 *
	 * @return void
	 */
	public function render_templates() {
		BFNET_Security::require_cap( 'bfnet_manage_templates' );

		$template_id = isset( $_GET['template'] ) ? (int) $_GET['template'] : 0;

		if ( 'edit' === $this->action() && $template_id ) {
			$template = BFNET_Template::get( $template_id );

			/**
			 * The screen that edits one design, if anything can edit one.
			 *
			 * @param string $view     View name, empty when nothing edits designs.
			 * @param object $template The design.
			 */
			$view = $template ? (string) apply_filters( 'bfnet_design_editor', '', $template ) : '';

			if ( $view ) {
				$this->view( $view, array( 'template' => $template ) );
				return;
			}
		}

		$this->view( 'templates' );
	}

	/**
	 * Organizers.
	 *
	 * @return void
	 */
	public function render_organizers() {
		BFNET_Security::require_cap( 'bfnet_manage_events' );
		$this->view( 'organizers' );
	}

	/**
	 * Venues.
	 *
	 * @return void
	 */
	public function render_venues() {
		BFNET_Security::require_cap( 'bfnet_manage_events' );
		$this->view( 'venues' );
	}

	/**
	 * Settings.
	 *
	 * @return void
	 */
	public function render_settings() {
		BFNET_Security::require_cap( 'bfnet_manage_settings' );
		$this->view( 'settings' );
	}

	/**
	 * Diagnostics, archiving and data reset.
	 *
	 * @return void
	 */
	public function render_tools() {
		BFNET_Security::require_cap( 'bfnet_manage_settings' );
		$this->view( 'tools' );
	}

	/**
	 * Activity log.
	 *
	 * @return void
	 */
	public function render_logs() {
		BFNET_Security::require_cap( 'bfnet_manage_settings' );
		$this->view( 'logs' );
	}

	/* --------------------------------------------------------------------- */
	/* Template preview endpoint                                             */
	/* --------------------------------------------------------------------- */

	/**
	 * Serve a signed template preview image from private storage.
	 *
	 * @return void
	 */
	/**
	 * Render a design with sample data on it, and stream it.
	 *
	 * Looking at the bare artwork tells you nothing about whether the engraving
	 * lands where it should. This is the picture that answers the question — and
	 * when it cannot be produced, it says why in words instead of leaving an
	 * empty frame.
	 *
	 * @return void
	 */
	private function serve_template_proof() {
		BFNET_Security::require_cap( 'bfnet_manage_templates' );

		$id       = isset( $_GET['bfnet_proof'] ) ? (int) $_GET['bfnet_proof'] : 0;
		$template = BFNET_Template::get( $id );

		if ( ! $template ) {
			wp_die( esc_html__( 'Design not found.', 'bitforgenet-guest-tickets' ), '', array( 'response' => 404 ) );
		}

		if ( function_exists( 'wp_raise_memory_limit' ) ) {
			wp_raise_memory_limit( 'image' );
		}

		$jpeg = BFNET_Ticket_Renderer::render_sample( $template );

		if ( is_wp_error( $jpeg ) || ! $jpeg ) {
			nocache_headers();
			header( 'Content-Type: text/html; charset=utf-8' );
			echo '<p style="font:14px system-ui;color:#c5221f;padding:16px">'
				. esc_html(
					is_wp_error( $jpeg )
						? $jpeg->get_error_message()
						: __( 'The proof could not be drawn. The design may be too large for this server to hold in memory.', 'bitforgenet-guest-tickets' )
				)
				. '</p>';
			exit;
		}

		nocache_headers();
		header( 'Content-Type: image/jpeg' );
		header( 'Content-Length: ' . strlen( $jpeg ) );
		echo $jpeg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- a JPEG body, already sent with its own Content-Type.
		exit;
	}

	public function serve_template_preview() {
		if ( ! empty( $_GET['bfnet_proof'] ) ) {
			$this->serve_template_proof();
			return;
		}

		if ( empty( $_GET['bfnet_preview'] ) ) {
			return;
		}

		BFNET_Security::require_cap( 'bfnet_manage_templates' );

		$id        = (int) $_GET['bfnet_preview'];
		$expires   = isset( $_GET['expires'] ) ? (int) $_GET['expires'] : 0;
		$signature = isset( $_GET['signature'] ) ? sanitize_text_field( wp_unslash( $_GET['signature'] ) ) : '';

		$template = BFNET_Template::get( $id );
		if ( ! $template ) {
			wp_die( esc_html__( 'Template not found.', 'bitforgenet-guest-tickets' ), '', array( 'response' => 404 ) );
		}

		$file = ! empty( $template->preview_file ) ? $template->preview_file : $template->background_file;

		if ( ! BFNET_Security::verify_signature( 'template:' . $id . ':' . $file, $expires, $signature ) ) {
			wp_die( esc_html__( 'This preview link has expired. Reload the page.', 'bitforgenet-guest-tickets' ), '', array( 'response' => 403 ) );
		}

		$storage = BFNET_Install::storage();
		$path    = $storage['dir'] . '/templates/' . basename( $file );

		if ( ! is_readable( $path ) ) {
			wp_die( esc_html__( 'Preview image missing.', 'bitforgenet-guest-tickets' ), '', array( 'response' => 404 ) );
		}

		$info = @getimagesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
		header( 'Content-Type: ' . ( $info ? $info['mime'] : 'image/png' ) );
		header( 'Content-Length: ' . filesize( $path ) );
		header( 'Cache-Control: private, max-age=600' );
		readfile( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		exit;
	}

	/* --------------------------------------------------------------------- */
	/* Form handling                                                         */
	/* --------------------------------------------------------------------- */

	/**
	 * Dispatch admin POST/GET actions.
	 *
	 * @return void
	 */
	public function route_actions() {
		$action = isset( $_REQUEST['bfnet_do'] ) ? sanitize_key( wp_unslash( $_REQUEST['bfnet_do'] ) ) : '';
		if ( ! $action ) {
			return;
		}

		$handlers = array(
			'save_event'        => array( 'bfnet_manage_events', 'handle_save_event' ),
			'wizard'            => array( 'bfnet_manage_events', 'handle_wizard' ),
			'delete_event'      => array( 'bfnet_manage_events', 'handle_delete_event' ),
			'duplicate_event'   => array( 'bfnet_manage_events', 'handle_duplicate_event' ),
			'order_status'      => array( 'bfnet_validate_payments', 'handle_order_status' ),
			'order_bulk'        => array( 'bfnet_validate_payments', 'handle_order_bulk' ),
			'order_note'        => array( 'bfnet_manage_orders', 'handle_order_note' ),
			'order_delete'      => array( 'bfnet_manage_orders', 'handle_order_delete' ),
			'order_resend'      => array( 'bfnet_manage_orders', 'handle_order_resend' ),
			'ticket_action'     => array( 'bfnet_manage_tickets', 'handle_ticket_action' ),
			'save_organizer'    => array( 'bfnet_manage_events', 'handle_save_organizer' ),
			'delete_organizer'  => array( 'bfnet_manage_events', 'handle_delete_organizer' ),
			'save_venue'        => array( 'bfnet_manage_events', 'handle_save_venue' ),
			'delete_venue'      => array( 'bfnet_manage_events', 'handle_delete_venue' ),
			'save_settings'     => array( 'bfnet_manage_settings', 'handle_save_settings' ),
			'reset_code'        => array( 'bfnet_manage_settings', 'handle_reset_code' ),
			'reset_confirm'     => array( 'bfnet_manage_settings', 'handle_reset_confirm' ),
			'send_test_email'   => array( 'bfnet_manage_settings', 'handle_send_test_email' ),
			'door_pin'          => array( 'bfnet_scan_tickets', 'handle_door_pin' ),
			'repair_db'         => array( 'bfnet_manage_settings', 'handle_repair_db' ),
			'roll_call'         => array( 'bfnet_view_reports', 'handle_roll_call' ),
			'export_csv'        => array( 'bfnet_view_reports', 'handle_export_csv' ),
		);

		/**
		 * Actions a module adds to the admin's front controller.
		 *
		 * Each entry is array( capability, handler ). A handler is either the
		 * name of a method on this class, or any callable — which is how a
		 * module that lives in its own file gets a screen without this class
		 * having to know it exists. The callable is handed the admin object, so
		 * it can read the request and redirect the same way we do.
		 *
		 * @param array $handlers Action name => array( capability, handler ).
		 */
		$handlers = apply_filters( 'bfnet_admin_actions', $handlers );

		if ( ! isset( $handlers[ $action ] ) ) {
			return;
		}

		list( $cap, $method ) = $handlers[ $action ];

		BFNET_Security::verify_admin_request( 'bfnet_' . $action, $cap );

		if ( is_string( $method ) && method_exists( $this, $method ) ) {
			$this->{$method}();
			return;
		}

		if ( is_callable( $method ) ) {
			call_user_func( $method, $this );
		}
	}

	/**
	 * Issue or withdraw the door code for an event.
	 *
	 * @return void
	 */
	private function handle_door_pin() {
		$event_id = (int) $this->post( 'event', 0 );

		if ( ! $event_id ) {
			self::notice( __( 'Choose an event first.', 'bitforgenet-guest-tickets' ), 'error' );
			$this->redirect( 'bitforgenet-guest-tickets-scanner' );
		}

		if ( $this->post( 'revoke', '' ) ) {
			BFNET_Door::revoke_pin( $event_id );
			self::notice( __( 'Every door station has been signed out. Issue a new code when you need one.', 'bitforgenet-guest-tickets' ) );
			$this->redirect( 'bitforgenet-guest-tickets-scanner', array( 'event' => $event_id ) );
		}

		$pin = BFNET_Door::issue_pin( $event_id );

		$this->redirect(
			'bitforgenet-guest-tickets-scanner',
			array(
				'event' => $event_id,
				'pin'   => $pin,
			)
		);
	}



	/**
	 * Stream the printable door list.
	 *
	 * @return void
	 */
	private function handle_roll_call() {
		$event_id = isset( $_REQUEST['event'] ) ? (int) $_REQUEST['event'] : 0;
		$filter = isset( $_REQUEST['filter'] ) ? sanitize_key( wp_unslash( $_REQUEST['filter'] ) ) : 'paid';
		$filter = isset( BFNET_Roll_Call::filters()[ $filter ] ) ? $filter : 'paid';

		$pdf = BFNET_Roll_Call::build( $event_id, $filter );

		if ( is_wp_error( $pdf ) ) {
			self::notice( $pdf->get_error_message(), 'error' );
			$this->redirect( 'bitforgenet-guest-tickets-scanner', array( 'event' => $event_id ) );
		}

		$event = BFNET_Event::get( $event_id );
		$name  = sanitize_file_name( ( $event ? $event->slug : 'event' ) . '-door-list-' . $filter . '.pdf' );

		nocache_headers();
		header( 'Content-Type: application/pdf' );
		header( 'Content-Disposition: attachment; filename="' . $name . '"' );
		header( 'Content-Length: ' . strlen( $pdf ) );

		echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- a PDF body, already sent with its own Content-Type.
		exit;
	}


	/**
	 * Bring the database back into line with the plugin.
	 *
	 * @return void
	 */
	private function handle_repair_db() {
		BFNET_Install::repair();

		$events  = BFNET_Install::columns( BFNET_Install::table( 'events' ) );
		$missing = array_diff( array( 'tables_mode', 'table_seats', 'menu_enabled', 'sponsors_image' ), $events );

		if ( $missing ) {
			self::notice(
				sprintf(
					/* translators: %s: list of column names. */
					__( 'The repair ran but %s are still missing. Your database user may not be allowed to alter tables — ask your host.', 'bitforgenet-guest-tickets' ),
					implode( ', ', $missing )
				),
				'error'
			);
		} else {
			self::notice( __( 'The database is up to date. Every field on the event screen can be saved.', 'bitforgenet-guest-tickets' ) );
		}

		$this->redirect( 'bitforgenet-guest-tickets-tools' );
	}

	/**
	 * Redirect back to a plugin screen.
	 *
	 * @param string $page Page slug.
	 * @param array  $args Query args.
	 * @return void
	 */
	public function redirect( $page, $args = array() ) {
		wp_safe_redirect( BFNET_Util::admin_url( $page, $args ) );
		exit;
	}

	/**
	 * Read a POST value.
	 *
	 * @param string $key     Field name.
	 * @param mixed  $default Default value.
	 * @return mixed
	 */
	public function post( $key, $default = '' ) {
		// The caller decides what this value is: every handler casts it, or runs it
		// through a sanitiser, before it goes anywhere. Unslashing is all that can
		// honestly be done here, where the field's type is not yet known.
		if ( ! isset( $_POST[ $key ] ) ) {
			return $default;
		}
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- see above.
		return is_array( $_POST[ $key ] ) ? wp_unslash( $_POST[ $key ] ) : wp_unslash( $_POST[ $key ] );
	}

	/**
	 * Save an event.
	 *
	 * @return void
	 */
	/**
	 * Read the event editor into a payload.
	 *
	 * Kept apart from the handler so the classic form post and the background
	 * save cannot drift into reading the screen differently.
	 *
	 * @return array
	 */
	private function event_payload() {
		$id      = (int) $this->post( 'event_id', 0 );
		$fields  = $this->post( 'bfnet_fields', array() );
		$current = $id ? BFNET_Event::get( $id ) : null;

		// Everything the form can send, read from the request. Two names differ
		// from their column — price is entered in dollars, and the line-up and
		// the zones arrive as repeated groups — so those are read by hand.
		$posted = array(
			'id'                     => $id,
			'title'                  => $this->post( 'title' ),
			'slug'                   => $this->post( 'slug' ),
			'subtitle'               => $this->post( 'subtitle' ),
			'series'                 => $this->post( 'series' ),
			'edition'                => $this->post( 'edition' ),
			'description'            => $this->post( 'description' ),
			'short_description'      => $this->post( 'short_description' ),
			'cover_id'               => (int) $this->post( 'cover_id', 0 ),
			'organizer_id'           => (int) $this->post( 'organizer_id', 0 ),
			'venue_id'               => (int) $this->post( 'venue_id', 0 ),
			'template_id'            => (int) $this->post( 'template_id', 0 ),
			'starts_at'              => $this->post( 'starts_at' ),
			'ends_at'                => $this->post( 'ends_at' ),
			'doors_open_at'          => $this->post( 'doors_open_at' ),
			'timezone'               => $this->post( 'timezone' ),
			'age_check_mode'         => $this->post( 'age_check_mode', 'off' ),
			'terms_page_id'          => (int) $this->post( 'terms_page_id', 0 ),
			'regulation_page_id'     => (int) $this->post( 'regulation_page_id', 0 ),
			'dress_code'             => $this->post( 'dress_code' ),
			'min_age'                => (int) $this->post( 'min_age', 0 ),
			'tables_mode'            => $this->post( 'tables_mode', 'manual' ),
			'tables_from'            => (int) $this->post( 'tables_from', 1 ),
			'tables_to'              => (int) $this->post( 'tables_to', 0 ),
			'table_seats'            => (int) $this->post( 'table_seats', 0 ),
			'table_zones'            => $this->post( 'table_zones', array() ),
			'sponsors_image'         => (int) $this->post( 'sponsors_image', 0 ),
			'menu_enabled'           => (int) $this->post( 'menu_enabled', 0 ),
			'menu_title'             => $this->post( 'menu_title' ),
			'menu_body'              => $this->post( 'menu_body' ),
			'capacity'               => (int) $this->post( 'capacity', 0 ),
			'price_cents'            => BFNET_Util::to_cents( $this->post( 'price', '0' ) ),
			'currency'               => $this->post( 'currency', BFNET_Settings::get( 'currency', 'CAD' ) ),
			'max_per_order'          => (int) $this->post( 'max_per_order', 10 ),
			'collect_guest_names'    => (int) $this->post( 'collect_guest_names', 0 ),
			'ticket_prefix'          => $this->post( 'ticket_prefix' ),
			'status'                 => $this->post( 'status', 'draft' ),
			'registration_opens_at'  => $this->post( 'registration_opens_at' ),
			'registration_closes_at' => $this->post( 'registration_closes_at' ),
			'whatsapp_number'        => $this->post( 'whatsapp_number' ),
			'payment_instructions'   => $this->post( 'payment_instructions' ),
			'terms'                  => $this->post( 'terms' ),
			'lineup'                 => $this->collect_lineup(),
		);

		// A whole-form submission — the wizard, or anything without a tab — is
		// trusted with everything it sent.
		if ( ! is_array( $fields ) || ! $fields ) {
			return $posted;
		}

		// One tab was saved. It may write only what it declared it owns; every
		// other field keeps the value already stored. Without this, saving the
		// door rules would blank the price, because a field that was never on
		// the page posts exactly like a field somebody emptied.
		$owned = array_map( 'sanitize_key', (array) $fields );
		$owned = array_map(
			static function ( $name ) {
				return 'price' === $name ? 'price_cents' : $name;
			},
			$owned
		);

		// A brand-new event has nothing stored to protect, so the posted array —
		// which already carries this class's own defaults for every field the
		// form did not show — is the right base. An existing one is merged onto.
		$payload = $current ? self::event_as_payload( $current ) : $posted;
		$payload['id'] = $id;
		$payload['status'] = $posted['status']; // The save bar carries it on every tab.

		foreach ( $owned as $key ) {
			if ( array_key_exists( $key, $posted ) ) {
				$payload[ $key ] = $posted[ $key ];
			}
		}

		return $payload;
	}

	/**
	 * The stored event, in the shape event_payload() speaks.
	 *
	 * A row and a payload are nearly the same thing but not quite: the line-up
	 * and the zones are JSON in the database and arrays in the payload. Getting
	 * that conversion wrong is how a tab save quietly empties the line-up.
	 *
	 * @param object|null $event Event row.
	 * @return array
	 */
	private static function event_as_payload( $event ) {
		$keys = array(
			'title', 'slug', 'subtitle', 'series', 'edition', 'description', 'short_description',
			'cover_id', 'organizer_id', 'venue_id', 'template_id', 'starts_at', 'ends_at',
			'doors_open_at', 'timezone', 'age_check_mode', 'terms_page_id', 'regulation_page_id',
			'dress_code', 'min_age', 'tables_mode', 'tables_from', 'tables_to', 'table_seats',
			'sponsors_image', 'menu_enabled', 'menu_title', 'menu_body', 'capacity', 'price_cents',
			'currency', 'max_per_order', 'collect_guest_names', 'ticket_prefix', 'status',
			'registration_opens_at', 'registration_closes_at', 'whatsapp_number',
			'payment_instructions', 'terms',
		);

		$out = array( 'id' => 0 );

		foreach ( $keys as $key ) {
			$out[ $key ] = ( $event && isset( $event->$key ) ) ? $event->$key : '';
		}

		$out['lineup']      = $event ? BFNET_Event::lineup( $event ) : array();
		$out['table_zones'] = $event ? BFNET_Util::json( isset( $event->table_zones ) ? $event->table_zones : '' ) : array();

		if ( ! is_array( $out['table_zones'] ) ) {
			$out['table_zones'] = array();
		}

		return $out;
	}


	private function handle_save_event() {
		$data = $this->event_payload();

		$result = BFNET_Event::save( $data );

		if ( is_wp_error( $result ) ) {
			self::notice( $result->get_error_message(), 'error' );
			$this->redirect(
				'bitforgenet-guest-tickets-events',
				array_filter(
					array(
						'action' => $data['id'] ? 'edit' : 'new',
						'event'  => $data['id'],
						'tab'    => sanitize_key( (string) $this->post( 'bfnet_tab', '' ) ),
					)
				)
			);
		}

		/**
		 * An event has just been written.
		 *
		 * A module that keeps its own rows alongside an event — price tiers, a
		 * seating plan — syncs them here. The admin object comes along so a
		 * module can read the fields its own tab posted.
		 *
		 * @param int       $event_id The event.
		 * @param array     $data     The payload that was saved.
		 * @param BFNET_Admin $admin    The admin screen doing the saving.
		 */
		do_action( 'bfnet_event_saved', (int) $result, $data, $this );

		// Back to the tab that was saved, not to the first one: being thrown to
		// the top of the screen after every save is how a form feels hostile.
		$tab = sanitize_key( (string) $this->post( 'bfnet_tab', '' ) );

		self::notice( __( 'Saved.', 'bitforgenet-guest-tickets' ) );
		$this->redirect(
			'bitforgenet-guest-tickets-events',
			array_filter(
				array(
					'action' => 'edit',
					'event'  => $result,
					'tab'    => $tab,
				)
			)
		);
	}

	/**
	 * Build the line-up array from repeated fields.
	 *
	 * @return array
	 */
	private function collect_lineup() {
		$raw = $this->post( 'lineup', array() );
		if ( ! is_array( $raw ) ) {
			return array();
		}

		$out = array();
		foreach ( $raw as $item ) {
			if ( empty( $item['name'] ) ) {
				continue;
			}
			$out[] = array(
				'role' => isset( $item['role'] ) ? $item['role'] : '',
				'name' => $item['name'],
				'note' => isset( $item['note'] ) ? $item['note'] : '',
			);
		}

		return $out;
	}

	/**
	 * Delete an event.
	 *
	 * @return void
	 */
	private function handle_delete_event() {
		$id = isset( $_REQUEST['event'] ) ? (int) $_REQUEST['event'] : 0;
		BFNET_Event::delete( $id );
		self::notice( __( 'Event deleted with all of its bookings and tickets.', 'bitforgenet-guest-tickets' ), 'warning' );
		$this->redirect( 'bitforgenet-guest-tickets-events' );
	}

	/**
	 * Duplicate an event as a new draft.
	 *
	 * @return void
	 */
	private function handle_duplicate_event() {
		$id     = isset( $_REQUEST['event'] ) ? (int) $_REQUEST['event'] : 0;
		$source = BFNET_Event::get( $id );

		if ( ! $source ) {
			$this->redirect( 'bitforgenet-guest-tickets-events' );
		}

		$data = (array) $source;
		unset( $data['id'], $data['uuid'], $data['created_at'], $data['updated_at'] );

		$data['title']       = sprintf(
			/* translators: %s: original event title. */
			__( '%s (copy)', 'bitforgenet-guest-tickets' ),
			$source->title
		);
		$data['slug']        = '';
		$data['status']      = 'draft';
		$data['sold_count']  = 0;
		$data['lineup']      = BFNET_Event::lineup( $source );
		$data['price_cents'] = (int) $source->price_cents;

		$new = BFNET_Event::save( $data );

		if ( is_wp_error( $new ) ) {
			self::notice( $new->get_error_message(), 'error' );
			$this->redirect( 'bitforgenet-guest-tickets-events' );
		}

		/**
		 * An event has been copied.
		 *
		 * Anything a module keeps beside the original belongs beside the copy.
		 *
		 * @param int $new_id The copy.
		 * @param int $old_id The original.
		 */
		do_action( 'bfnet_event_duplicated', (int) $new, (int) $id );

		self::notice( __( 'Event duplicated as a draft.', 'bitforgenet-guest-tickets' ) );
		$this->redirect( 'bitforgenet-guest-tickets-events', array( 'action' => 'edit', 'event' => $new ) );
	}

	/**
	 * Change one order's payment status.
	 *
	 * @return void
	 */
	private function handle_order_status() {
		$order_id = (int) $this->post( 'order_id', 0 );
		$status   = sanitize_key( $this->post( 'status', '' ) );
		$ref      = $this->post( 'payment_reference', '' );

		if ( 'paid' === $status ) {
			$result = BFNET_Order::mark_paid( $order_id, $ref, sanitize_key( $this->post( 'payment_method', 'manual' ) ) );
			if ( ! is_wp_error( $result ) ) {
				self::notice( __( 'Payment confirmed. Tickets are being issued and emailed.', 'bitforgenet-guest-tickets' ) );
			}
		} elseif ( 'pending' === $status ) {
			BFNET_Order::mark_unpaid( $order_id, $this->post( 'reason', '' ) );
			self::notice( __( 'Order moved back to awaiting payment. Its tickets have been voided.', 'bitforgenet-guest-tickets' ), 'warning' );
		} else {
			BFNET_Order::set_status( $order_id, $status, $this->post( 'reason', '' ) );
			self::notice( __( 'Order status updated.', 'bitforgenet-guest-tickets' ) );
		}

		$this->redirect( 'bitforgenet-guest-tickets-orders', array( 'action' => 'view', 'order' => $order_id ) );
	}

	/**
	 * Apply a bulk action to selected orders.
	 *
	 * @return void
	 */
	private function handle_order_bulk() {
		$ids    = array_map( 'intval', (array) $this->post( 'orders', array() ) );
		$action = sanitize_key( $this->post( 'bulk_action', '' ) );
		$count  = 0;

		foreach ( $ids as $id ) {
			if ( ! $id ) {
				continue;
			}
			if ( 'mark_paid' === $action ) {
				BFNET_Order::mark_paid( $id, __( 'Bulk validation', 'bitforgenet-guest-tickets' ) );
				$count++;
			} elseif ( 'cancel' === $action ) {
				BFNET_Order::set_status( $id, 'cancelled', __( 'Bulk cancellation', 'bitforgenet-guest-tickets' ) );
				$count++;
			} elseif ( 'delete' === $action ) {
				BFNET_Order::delete( $id );
				$count++;
			}
		}

		/* translators: %d: number of orders affected. */
		self::notice( sprintf( _n( '%d booking updated.', '%d bookings updated.', $count, 'bitforgenet-guest-tickets' ), $count ) );

		$this->redirect( 'bitforgenet-guest-tickets-orders', array( 'event' => (int) $this->post( 'event_filter', 0 ) ) );
	}

	/**
	 * Save the internal note on an order.
	 *
	 * @return void
	 */
	private function handle_order_note() {
		$order_id = (int) $this->post( 'order_id', 0 );
		BFNET_Order::set_admin_note( $order_id, $this->post( 'admin_note', '' ) );
		self::notice( __( 'Note saved.', 'bitforgenet-guest-tickets' ) );
		$this->redirect( 'bitforgenet-guest-tickets-orders', array( 'action' => 'view', 'order' => $order_id ) );
	}

	/**
	 * Delete an order.
	 *
	 * @return void
	 */
	private function handle_order_delete() {
		$order_id = isset( $_REQUEST['order'] ) ? (int) $_REQUEST['order'] : 0;
		BFNET_Order::delete( $order_id );
		self::notice( __( 'Booking deleted.', 'bitforgenet-guest-tickets' ), 'warning' );
		$this->redirect( 'bitforgenet-guest-tickets-orders' );
	}

	/**
	 * Rebuild and resend the tickets of an order.
	 *
	 * @return void
	 */
	private function handle_order_resend() {
		$order_id = isset( $_REQUEST['order'] ) ? (int) $_REQUEST['order'] : 0;
		$result   = BFNET_Fulfilment::fulfil( $order_id, true );

		if ( is_wp_error( $result ) ) {
			self::notice( $result->get_error_message(), 'error' );
		} else {
			self::notice(
				sprintf(
					/* translators: %d: number of tickets sent. */
					_n( '%d ticket regenerated and emailed. Previous QR codes are now invalid.', '%d tickets regenerated and emailed. Previous QR codes are now invalid.', (int) $result['tickets'], 'bitforgenet-guest-tickets' ),
					(int) $result['tickets']
				)
			);
		}

		$this->redirect( 'bitforgenet-guest-tickets-orders', array( 'action' => 'view', 'order' => $order_id ) );
	}

	/**
	 * Act on a single ticket.
	 *
	 * @return void
	 */
	private function handle_ticket_action() {
		$ticket_id = isset( $_REQUEST['ticket'] ) ? (int) $_REQUEST['ticket'] : 0;
		$what      = sanitize_key( isset( $_REQUEST['what'] ) ? wp_unslash( $_REQUEST['what'] ) : '' );
		$ticket    = BFNET_Ticket::get( $ticket_id );

		if ( ! $ticket ) {
			$this->redirect( 'bitforgenet-guest-tickets-tickets' );
		}

		switch ( $what ) {
			case 'void':
				BFNET_Ticket::void( $ticket_id, __( 'Voided by an administrator', 'bitforgenet-guest-tickets' ) );
				self::notice( __( 'Ticket voided. Its QR code will now be refused at the door.', 'bitforgenet-guest-tickets' ), 'warning' );
				break;

			case 'undo_checkin':
				BFNET_Ticket::undo_check_in( $ticket_id );
				self::notice( __( 'Check-in reverted.', 'bitforgenet-guest-tickets' ) );
				break;

			case 'resend':
				$result = BFNET_Fulfilment::resend_ticket( $ticket_id );
				if ( is_wp_error( $result ) ) {
					self::notice( $result->get_error_message(), 'error' );
				} else {
					self::notice( __( 'Ticket regenerated with a new QR code and emailed.', 'bitforgenet-guest-tickets' ) );
				}
				break;

			case 'checkin':
				BFNET_Ticket::log_scan( $ticket_id, (int) $ticket->event_id, 'manual', __( 'Manual', 'bitforgenet-guest-tickets' ) );
				global $wpdb;
				$wpdb->update(
					BFNET_Ticket::table(),
					array(
						'checked_in'    => 1,
						'checked_in_at' => current_time( 'mysql' ),
						'checked_in_by' => get_current_user_id(),
						'status'        => 'used',
					),
					array( 'id' => $ticket_id ),
					array( '%d', '%s', '%d', '%s' ),
					array( '%d' )
				);
				BFNET_Order::refresh_event_counters( (int) $ticket->event_id );
				self::notice( __( 'Guest checked in manually.', 'bitforgenet-guest-tickets' ) );
				break;
		}

		$back = isset( $_REQUEST['back'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['back'] ) ) : '';
		if ( 'order' === $back ) {
			$this->redirect( 'bitforgenet-guest-tickets-orders', array( 'action' => 'view', 'order' => (int) $ticket->order_id ) );
		}
		$this->redirect( 'bitforgenet-guest-tickets-tickets', array( 'event' => (int) $ticket->event_id ) );
	}

	/**
	 * Save an organizer.
	 *
	 * @return void
	 */
	private function handle_save_organizer() {
		$result = BFNET_Organizer::save(
			array(
				'id'              => (int) $this->post( 'organizer_id', 0 ),
				'name'            => $this->post( 'name' ),
				'slug'            => $this->post( 'slug' ),
				'tagline'         => $this->post( 'tagline' ),
				'description'     => $this->post( 'description' ),
				'logo_id'         => (int) $this->post( 'logo_id', 0 ),
				'email'           => $this->post( 'email' ),
				'phone'           => $this->post( 'phone' ),
				'whatsapp'        => $this->post( 'whatsapp' ),
				'website'         => $this->post( 'website' ),
				'instagram'       => $this->post( 'instagram' ),
				'facebook'        => $this->post( 'facebook' ),
				'brand_primary'   => $this->post( 'brand_primary' ),
				'brand_secondary' => $this->post( 'brand_secondary' ),
				'status'          => $this->post( 'status', 'active' ),
			)
		);

		self::notice(
			is_wp_error( $result ) ? $result->get_error_message() : __( 'Organizer saved.', 'bitforgenet-guest-tickets' ),
			is_wp_error( $result ) ? 'error' : 'success'
		);

		$this->redirect( 'bitforgenet-guest-tickets-organizers' );
	}

	/**
	 * Delete an organizer.
	 *
	 * @return void
	 */
	private function handle_delete_organizer() {
		$id     = isset( $_REQUEST['organizer'] ) ? (int) $_REQUEST['organizer'] : 0;
		$result = BFNET_Organizer::delete( $id );

		self::notice(
			is_wp_error( $result ) ? $result->get_error_message() : __( 'Organizer deleted.', 'bitforgenet-guest-tickets' ),
			is_wp_error( $result ) ? 'error' : 'warning'
		);

		$this->redirect( 'bitforgenet-guest-tickets-organizers' );
	}

	/**
	 * Save a venue.
	 *
	 * @return void
	 */
	private function handle_save_venue() {
		$result = BFNET_Venue::save(
			array(
				'id'              => (int) $this->post( 'venue_id', 0 ),
				'name'            => $this->post( 'name' ),
				'address'         => $this->post( 'address' ),
				'city'            => $this->post( 'city' ),
				'state'           => $this->post( 'state' ),
				'postcode'        => $this->post( 'postcode' ),
				'country'         => $this->post( 'country' ),
				'google_maps_url' => $this->post( 'google_maps_url' ),
				'latitude'        => $this->post( 'latitude' ),
				'longitude'       => $this->post( 'longitude' ),
				'capacity'        => (int) $this->post( 'capacity', 0 ),
				'phone'           => $this->post( 'phone' ),
				'notes'           => $this->post( 'notes' ),
				'status'          => $this->post( 'status', 'active' ),
			)
		);

		self::notice(
			is_wp_error( $result ) ? $result->get_error_message() : __( 'Venue saved.', 'bitforgenet-guest-tickets' ),
			is_wp_error( $result ) ? 'error' : 'success'
		);

		$this->redirect( 'bitforgenet-guest-tickets-venues' );
	}

	/**
	 * Turn the first page of a PDF into a print-resolution PNG.
	 *
	 * @param string $path Uploaded PDF.
	 * @param int    $dpi  Resolution to render at.
	 * @return string|WP_Error Path to the PNG.
	 */
	private function rasterise_pdf( $path, $dpi = 300 ) {
		if ( ! class_exists( 'Imagick' ) ) {
			return new WP_Error(
				'bfnet_no_imagick',
				__( 'This server cannot open PDFs — it has no Imagick extension. Export your artboard as a PNG instead: the plugin no longer lets WordPress shrink it, so the pixels arrive exactly as you exported them.', 'bitforgenet-guest-tickets' )
			);
		}

		$dpi = max( 72, min( 1200, (int) $dpi ) );
		$out = preg_replace( '/\.pdf$/i', '', $path ) . '-' . $dpi . '.png';

		try {
			$pdf = new Imagick();
			$pdf->setResolution( $dpi, $dpi );
			$pdf->readImage( $path . '[0]' );
			$pdf->setImageFormat( 'png' );

			// Flatten onto white: a transparent PDF background composited later
			// would leave the ticket see-through.
			$pdf->setImageBackgroundColor( new ImagickPixel( 'white' ) );
			$flat = method_exists( $pdf, 'mergeImageLayers' ) ? $pdf->mergeImageLayers( Imagick::LAYERMETHOD_FLATTEN ) : $pdf;
			$flat->writeImage( $out );

			$flat->clear();
			$pdf->clear();
		} catch ( Exception $e ) {
			return new WP_Error(
				'bfnet_pdf_failed',
				sprintf(
					/* translators: %s: the error the imaging library reported. */
					__( 'That PDF could not be opened (%s). Many servers block PDF rendering for security. Export a PNG instead.', 'bitforgenet-guest-tickets' ),
					$e->getMessage()
				)
			);
		}

		return is_readable( $out ) ? $out : new WP_Error( 'bfnet_pdf_failed', __( 'The PDF produced no image.', 'bitforgenet-guest-tickets' ) );
	}

	/**
	 * Delete a venue.
	 *
	 * @return void
	 */
	private function handle_delete_venue() {
		$id     = isset( $_REQUEST['venue'] ) ? (int) $_REQUEST['venue'] : 0;
		$result = BFNET_Venue::delete( $id );

		self::notice(
			is_wp_error( $result ) ? $result->get_error_message() : __( 'Venue deleted.', 'bitforgenet-guest-tickets' ),
			is_wp_error( $result ) ? 'error' : 'warning'
		);

		$this->redirect( 'bitforgenet-guest-tickets-venues' );
	}






	/**
	 * Save the settings screen.
	 *
	 * @return void
	 */
	private function handle_save_settings() {
		$values = $this->post( 'bfnet_settings', array() );
		$fields = $this->post( 'bfnet_fields', array() );
		$tab    = sanitize_key( (string) $this->post( 'bfnet_tab', '' ) );

		// Every setting that is a checkbox. An unticked box posts nothing at all,
		// so these have to be read as absent-means-off rather than as untouched.
		$flags = array(
			'collect_guest_names', 'require_phone', 'require_terms', 'enable_honeypot',
			'block_disposable_email', 'auto_issue_on_paid', 'send_pending_email',
			'send_paid_email', 'attach_individual_pdfs', 'attach_combined_pdf',
			'email_download_links', 'allow_multi_scan', 'scan_sound',
			'delete_data_on_uninstall', 'debug_mode',
		);

		if ( ! is_array( $values ) ) {
			$values = array();
		}

		if ( is_array( $fields ) && $fields ) {
			// The tab said which settings it owns. Anything else in the request is
			// ignored, and — the point of the exercise — anything not on this tab
			// is left exactly as it was. A screen about door rules cannot empty the
			// email sender, and an unrendered checkbox is never read as "off".
			$fields = array_map( 'sanitize_key', (array) $fields );
			$scoped = array();

			foreach ( $fields as $key ) {
				if ( in_array( $key, $flags, true ) ) {
					$scoped[ $key ] = empty( $values[ $key ] ) ? 0 : 1;
				} elseif ( array_key_exists( $key, $values ) ) {
					$scoped[ $key ] = $values[ $key ];
				}
			}

			$values = $scoped;
		} else {
			// A form from before the tabs — or from somewhere else entirely.
			foreach ( $flags as $flag ) {
				$values[ $flag ] = empty( $values[ $flag ] ) ? 0 : 1;
			}
		}

		if ( $values ) {
			BFNET_Settings::save( $values );
		}

		BFNET_Log::audit( 'system', 0, 'settings_saved', array( 'tab' => $tab ) );
		self::notice( __( 'Saved.', 'bitforgenet-guest-tickets' ) );

		$this->redirect( 'bitforgenet-guest-tickets-settings', $tab ? array( 'tab' => $tab ) : array() );
	}

	/**
	 * Email a one-time code to the authorising address.
	 *
	 * @return void
	 */
	private function handle_reset_code() {
		$result = BFNET_Archive::request_code();

		if ( is_wp_error( $result ) ) {
			self::notice( $result->get_error_message(), 'error' );
		} else {
			self::notice(
				sprintf(
					/* translators: %s: email address. */
					__( 'A confirmation code has been sent to %s. It is valid for 15 minutes.', 'bitforgenet-guest-tickets' ),
					BFNET_Archive::authorised_email()
				)
			);
		}

		$this->redirect( 'bitforgenet-guest-tickets-tools' );
	}

	/**
	 * Archive and clear the booking data once the code checks out.
	 *
	 * @return void
	 */
	private function handle_reset_confirm() {
		$code    = (string) $this->post( 'reset_code', '' );
		$checked = BFNET_Archive::verify_code( $code );

		if ( is_wp_error( $checked ) ) {
			self::notice( $checked->get_error_message(), 'error' );
			$this->redirect( 'bitforgenet-guest-tickets-tools' );
		}

		$result = BFNET_Archive::archive_and_reset(
			array(
				'scope'    => sanitize_key( $this->post( 'scope', 'all' ) ),
				'event_id' => (int) $this->post( 'event_id', 0 ),
				'reason'   => (string) $this->post( 'reason', '' ),
				'files'    => ! empty( $this->post( 'delete_files', 0 ) ),
			)
		);

		if ( is_wp_error( $result ) ) {
			self::notice( $result->get_error_message(), 'error' );
		} else {
			self::notice(
				sprintf(
					/* translators: 1: bookings, 2: tickets, 3: scans. */
					__( 'Archived and cleared: %1$d bookings, %2$d tickets, %3$d scans. Everything is still readable in the archive below.', 'bitforgenet-guest-tickets' ),
					isset( $result['orders'] ) ? (int) $result['orders'] : 0,
					isset( $result['tickets'] ) ? (int) $result['tickets'] : 0,
					isset( $result['scans'] ) ? (int) $result['scans'] : 0
				)
			);
		}

		$this->redirect( 'bitforgenet-guest-tickets-tools' );
	}

	/**
	 * Send a diagnostic test email.
	 *
	 * @return void
	 */
	private function handle_send_test_email() {
		$to     = (string) $this->post( 'test_email', '' );
		$result = BFNET_Fulfilment::send_test_email( $to );

		self::notice(
			is_wp_error( $result ) ? $result->get_error_message() : __( 'Test email sent. If it arrives with the PDF attached, ticket delivery works on this server.', 'bitforgenet-guest-tickets' ),
			is_wp_error( $result ) ? 'error' : 'success'
		);

		$this->redirect( 'bitforgenet-guest-tickets-tools' );
	}

	/**
	 * Stream a CSV export.
	 *
	 * The shape of the file lives in BFNET_Export, which builds it from the
	 * database schema so that every field the plugin knows about gets a column
	 * whether or not this particular event uses it.
	 *
	 * @return void
	 */
	private function handle_export_csv() {
		$event_id = isset( $_REQUEST['event'] ) ? (int) $_REQUEST['event'] : 0;
		$what     = sanitize_key( isset( $_REQUEST['what'] ) ? wp_unslash( $_REQUEST['what'] ) : 'orders' );
		$fields   = isset( $_REQUEST['fields'] ) ? wp_unslash( $_REQUEST['fields'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised on the next line, whether it arrives as a list or as one string.
		$fields   = is_array( $fields ) ? array_map( 'sanitize_key', $fields ) : sanitize_text_field( (string) $fields );

		$what   = 'guests' === $what ? 'guests' : 'orders';
		$fields = BFNET_Export::chosen( $what, $fields );

		// Remembered per person, not per site: two managers can want different
		// columns out of the same event, and neither should have to re-tick them
		// every month.
		update_user_meta( get_current_user_id(), 'bfnet_export_fields_' . $what, $fields );

		BFNET_Export::stream( $what, $event_id, $fields );
	}

	/* --------------------------------------------------------------------- */
	/* The Google mailbox                                                     */
	/* --------------------------------------------------------------------- */





	/* --------------------------------------------------------------------- */
	/* The new-event wizard                                                   */
	/* --------------------------------------------------------------------- */

	/**
	 * Move one step through the wizard, saving as it goes.
	 *
	 * The draft exists from the end of step one onward, so every later step is
	 * an ordinary edit of a real row. That is what makes "you can leave and come
	 * back" true rather than a hopeful notice.
	 *
	 * @return void
	 */
	private function handle_wizard() {
		$step = max( 1, min( 4, (int) $this->post( 'step', 1 ) ) );
		$id   = (int) $this->post( 'event_id', 0 );
		$back = (bool) $this->post( 'back', 0 );
		$skip = (bool) $this->post( 'skip', 0 );

		if ( $back ) {
			$this->redirect( 'bitforgenet-guest-tickets-events', array( 'action' => 'new', 'step' => max( 1, $step - 1 ), 'event' => $id ) );
		}

		if ( $skip && $id ) {
			$this->redirect( 'bitforgenet-guest-tickets-events', array( 'action' => 'new', 'step' => min( 4, $step + 1 ), 'event' => $id ) );
		}

		// Each step owns exactly the fields it showed, so moving forward can
		// never blank an answer given on a previous one.
		$owns = array(
			1 => array( 'title', 'starts_at', 'doors_open_at', 'venue_id' ),
			2 => array( 'price', 'currency', 'capacity', 'max_per_order' ),
			3 => array( 'collect_guest_names', 'template_id', 'whatsapp_number' ),
			// The last step asks only whether to open the night. It still has to
			// declare something: an empty list means "whole form" further down,
			// and a whole-form save on the summary would blank the draft.
			4 => array( 'status' ),
		);

		$_POST['bfnet_fields'] = $owns[ $step ];

		// A new event has no stored row to merge onto, so step one starts from a
		// sensible draft rather than from whatever the defaults happen to be.
		if ( ! $id ) {
			$_POST['status'] = 'draft';
		}

		$data   = $this->event_payload();
		$result = BFNET_Event::save( $data );

		if ( is_wp_error( $result ) ) {
			self::notice( $result->get_error_message(), 'error' );
			$this->redirect( 'bitforgenet-guest-tickets-events', array( 'action' => 'new', 'step' => $step, 'event' => $id ) );
		}

		$id = (int) $result;

		if ( $step < 4 ) {
			$this->redirect( 'bitforgenet-guest-tickets-events', array( 'action' => 'new', 'step' => $step + 1, 'event' => $id ) );
		}

		$event = BFNET_Event::get( $id );
		self::notice(
			$event && 'open' === $event->status
				/* translators: %s: the event name. */
				? sprintf( __( '%s is created and open for bookings.', 'bitforgenet-guest-tickets' ), $event->title )
				/* translators: %s: the event name. */
				: sprintf( __( '%s is created as a draft. Open it when you are ready.', 'bitforgenet-guest-tickets' ), $event ? $event->title : '' )
		);

		$this->redirect( 'bitforgenet-guest-tickets-events', array( 'action' => 'edit', 'event' => $id ) );
	}
}
