Newer
Older
tonitalia-pluginsoci / includes / class-membership-card.php
<?php
/**
 * Membership card PDF generation.
 *
 * @package TonItaliaRegistration
 */

defined( 'ABSPATH' ) || exit;

use setasign\Fpdi\Fpdi;

/**
 * Class TON_Reg_Membership_Card
 */
class TON_Reg_Membership_Card {

	const OPTION_TEMPLATES   = 'ton_reg_card_templates';
	const OPTION_ACTIVE_YEAR = 'ton_reg_card_active_year';
	const SUBFOLDER          = 'tessere';

	const CONTEXT_ADMISSION = 'admission';
	const CONTEXT_BATCH     = 'batch';
	const CONTEXT_MANUAL    = 'manual';

	/**
	 * @return bool
	 */
	public static function is_available() {
		return is_readable( TON_REG_PLUGIN_DIR . 'vendor/autoload.php' );
	}

	/**
	 * Bootstrap PDF library.
	 */
	private static function load_library() {
		if ( ! self::is_available() ) {
			return new WP_Error( 'no_library', __( 'Libreria PDF non disponibile.', 'ton-italia-registration' ) );
		}

		require_once TON_REG_PLUGIN_DIR . 'vendor/autoload.php';
		return true;
	}

	/**
	 * @return bool
	 */
	public static function current_user_can_manage_cards() {
		return current_user_can( 'manage_options' );
	}

	/**
	 * @return void
	 */
	public static function assert_can_manage_cards() {
		if ( ! self::current_user_can_manage_cards() ) {
			wp_die( esc_html__( 'Non autorizzato.', 'ton-italia-registration' ), 403 );
		}
	}

	/**
	 * @return int
	 */
	public static function get_active_year() {
		$year = (int) get_option( self::OPTION_ACTIVE_YEAR, 0 );
		if ( $year < 2000 || $year > 2100 ) {
			$year = (int) gmdate( 'Y' );
		}
		return $year;
	}

	/**
	 * @param int $year Year.
	 * @return void
	 */
	public static function set_active_year( $year ) {
		update_option( self::OPTION_ACTIVE_YEAR, (int) $year );
	}

	/**
	 * @return array<int,array<string,mixed>>
	 */
	public static function get_all_templates() {
		$templates = get_option( self::OPTION_TEMPLATES, array() );
		return is_array( $templates ) ? $templates : array();
	}

	/**
	 * @param int $year Year.
	 * @return array<string,mixed>
	 */
	public static function get_template_config( $year ) {
		$templates = self::get_all_templates();
		$config    = $templates[ (int) $year ] ?? array();
		return wp_parse_args( $config, self::default_layout() );
	}

	/**
	 * @return array<string,mixed>
	 */
	public static function default_layout() {
		return array(
			'template_attachment_id' => 0,
			'name_x'                 => 42.5,
			'name_y'                 => 118.0,
			'font_family'            => 'Helvetica',
			'font_style'             => '',
			'font_size'              => 14,
			'text_color'             => '#000000',
			'uppercase'              => true,
			'align'                  => 'C',
			'cell_width'             => 80,
		);
	}

	/**
	 * @param int                  $year   Year.
	 * @param array<string,mixed> $config Config.
	 */
	public static function save_template_config( $year, $config ) {
		$year      = (int) $year;
		$templates = self::get_all_templates();
		$current   = $templates[ $year ] ?? array();

		$templates[ $year ] = array_merge( self::default_layout(), $current, $config );
		update_option( self::OPTION_TEMPLATES, $templates );
	}

	/**
	 * Upload a PDF card template to the media library.
	 *
	 * Uses wp_handle_upload + wp_insert_attachment without image metadata generation,
	 * which avoids fatal errors from Imagick/GD on some hosts when processing PDFs.
	 *
	 * @param array<string,mixed>|null $file Uploaded file array from $_FILES.
	 * @param int                      $year Template year.
	 * @return int|WP_Error Attachment ID.
	 */
	public static function upload_template_pdf( $file, $year ) {
		if ( ! self::current_user_can_manage_cards() ) {
			return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) );
		}

		if ( ! is_array( $file ) || empty( $file['tmp_name'] ) ) {
			if ( is_array( $file ) && isset( $file['error'] ) && UPLOAD_ERR_NO_FILE !== (int) $file['error'] ) {
				return new WP_Error( 'upload', self::upload_error_message( (int) $file['error'] ) );
			}
			return new WP_Error( 'no_file', __( 'Nessun file selezionato.', 'ton-italia-registration' ) );
		}

		require_once ABSPATH . 'wp-admin/includes/file.php';

		$check = wp_check_filetype( $file['name'], array( 'pdf' => 'application/pdf' ) );
		if ( empty( $check['ext'] ) || 'pdf' !== $check['ext'] ) {
			return new WP_Error( 'mime', __( 'Il template deve essere un file PDF.', 'ton-italia-registration' ) );
		}

		$upload = wp_handle_upload(
			$file,
			array(
				'test_form' => false,
				'mimes'     => array(
					'pdf' => 'application/pdf',
				),
			)
		);

		if ( ! empty( $upload['error'] ) ) {
			return new WP_Error( 'upload', $upload['error'] );
		}

		$attachment_id = wp_insert_attachment(
			array(
				'post_mime_type' => $upload['type'],
				'post_title'     => sprintf(
					/* translators: %d: year */
					__( 'Template tessera %d', 'ton-italia-registration' ),
					(int) $year
				),
				'post_content'   => '',
				'post_status'    => 'inherit',
				'guid'           => $upload['url'],
			),
			$upload['file']
		);

		if ( is_wp_error( $attachment_id ) ) {
			wp_delete_file( $upload['file'] );
			return $attachment_id;
		}

		wp_update_attachment_metadata( $attachment_id, array() );

		return (int) $attachment_id;
	}

	/**
	 * @param int $code PHP upload error code.
	 * @return string
	 */
	private static function upload_error_message( $code ) {
		$messages = array(
			UPLOAD_ERR_INI_SIZE   => __( 'Il file supera il limite di upload del server.', 'ton-italia-registration' ),
			UPLOAD_ERR_FORM_SIZE  => __( 'Il file supera il limite consentito.', 'ton-italia-registration' ),
			UPLOAD_ERR_PARTIAL    => __( 'Upload incompleto. Riprova.', 'ton-italia-registration' ),
			UPLOAD_ERR_NO_FILE    => __( 'Nessun file selezionato.', 'ton-italia-registration' ),
			UPLOAD_ERR_NO_TMP_DIR => __( 'Cartella temporanea mancante sul server.', 'ton-italia-registration' ),
			UPLOAD_ERR_CANT_WRITE => __( 'Impossibile scrivere il file sul server.', 'ton-italia-registration' ),
			UPLOAD_ERR_EXTENSION  => __( 'Upload bloccato da un\'estensione PHP.', 'ton-italia-registration' ),
		);

		return $messages[ $code ] ?? __( 'Errore nel caricamento del template.', 'ton-italia-registration' );
	}

	/**
	 * @param int    $registration_id Registration ID.
	 * @param int    $year            Year.
	 * @param array<string,mixed> $args Args.
	 * @return int|WP_Error Card ID.
	 */
	public static function generate_for_registration( $registration_id, $year, $args = array() ) {
		$defaults = array(
			'context'           => self::CONTEXT_MANUAL,
			'force_regenerate'  => false,
			'created_by'        => get_current_user_id(),
			'require_admin'     => true,
		);
		$args = wp_parse_args( $args, $defaults );

		if ( $args['require_admin'] && ! self::current_user_can_manage_cards() ) {
			return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) );
		}

		$row = TON_Reg_Database::get( $registration_id );
		if ( ! $row ) {
			return new WP_Error( 'not_found', __( 'Iscrizione non trovata.', 'ton-italia-registration' ) );
		}

		if ( 'admitted' !== $row->status || ! empty( $row->anonymized_at ) ) {
			return new WP_Error( 'not_admitted', __( 'La tessera può essere generata solo per soci ammessi.', 'ton-italia-registration' ) );
		}

		$year   = (int) $year;
		$config = self::get_template_config( $year );

		if ( empty( $config['template_attachment_id'] ) ) {
			return new WP_Error( 'no_template', __( 'Template PDF non configurato per questo anno.', 'ton-italia-registration' ) );
		}

		$existing = TON_Reg_Membership_Cards::get_by_registration_year( $registration_id, $year );
		if ( $existing && empty( $args['force_regenerate'] ) ) {
			return (int) $existing->id;
		}

		$library = self::load_library();
		if ( is_wp_error( $library ) ) {
			return $library;
		}

		$pdf_path = self::build_pdf_file( $row, $year, $config );
		if ( is_wp_error( $pdf_path ) ) {
			return $pdf_path;
		}

		$attachment_id = self::save_pdf_attachment( $row, $year, $pdf_path );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
		@unlink( $pdf_path );

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

		if ( $existing ) {
			if ( ! empty( $existing->attachment_id ) && (int) $existing->attachment_id !== (int) $attachment_id ) {
				wp_delete_attachment( (int) $existing->attachment_id, true );
			}

			TON_Reg_Membership_Cards::update(
				(int) $existing->id,
				array(
					'attachment_id'          => (int) $attachment_id,
					'template_attachment_id' => (int) $config['template_attachment_id'],
					'layout_snapshot'        => wp_json_encode( $config ),
					'generation_context'     => sanitize_key( $args['context'] ),
					'created_at'             => current_time( 'mysql', true ),
					'created_by'             => (int) $args['created_by'],
				)
			);

			return (int) $existing->id;
		}

		$card_id = TON_Reg_Membership_Cards::insert(
			array(
				'registration_id'        => (int) $registration_id,
				'user_id'                => (int) ( $row->user_id ?? 0 ) ?: null,
				'year'                   => $year,
				'attachment_id'          => (int) $attachment_id,
				'template_attachment_id' => (int) $config['template_attachment_id'],
				'layout_snapshot'        => wp_json_encode( $config ),
				'generation_context'     => sanitize_key( $args['context'] ),
				'created_by'             => (int) $args['created_by'],
			)
		);

		if ( ! $card_id ) {
			wp_delete_attachment( (int) $attachment_id, true );
			return new WP_Error( 'db_error', __( 'Impossibile salvare la tessera nel database.', 'ton-italia-registration' ) );
		}

		return (int) $card_id;
	}

	/**
	 * @param int    $registration_id Registration ID.
	 * @param string $old_status      Previous status.
	 * @param string $new_status      New status.
	 * @return int|WP_Error|null Card ID, error, or null if skipped.
	 */
	public static function maybe_generate_on_admission( $registration_id, $old_status, $new_status ) {
		if ( 'admitted' === $old_status || 'admitted' !== $new_status ) {
			return null;
		}

		$year = self::get_active_year();
		if ( TON_Reg_Membership_Cards::get_by_registration_year( $registration_id, $year ) ) {
			return null;
		}

		return self::generate_for_registration(
			$registration_id,
			self::get_active_year(),
			array(
				'context'       => self::CONTEXT_ADMISSION,
				'require_admin' => true,
			)
		);
	}

	/**
	 * @param object              $row    Registration row.
	 * @param int                 $year   Year.
	 * @param array<string,mixed> $config Layout config.
	 * @return string|WP_Error Temp file path.
	 */
	private static function build_pdf_file( $row, $year, $config ) {
		$template_path = get_attached_file( (int) $config['template_attachment_id'] );
		if ( ! $template_path || ! is_readable( $template_path ) ) {
			return new WP_Error( 'template_missing', __( 'File template PDF non trovato.', 'ton-italia-registration' ) );
		}

		try {
			$pdf = new Fpdi();
			$pdf->setSourceFile( $template_path );
			$tpl_id = $pdf->importPage( 1 );
			$size   = $pdf->getTemplateSize( $tpl_id );

			$orientation = ( $size['width'] > $size['height'] ) ? 'L' : 'P';
			$pdf->AddPage( $orientation, array( $size['width'], $size['height'] ) );
			$pdf->useTemplate( $tpl_id );

			$family = self::sanitize_font_family( (string) $config['font_family'] );
			$style  = self::sanitize_font_style( (string) $config['font_style'] );
			$size_pt = max( 6, min( 72, (float) $config['font_size'] ) );

			$pdf->SetFont( $family, $style, $size_pt );

			list( $r, $g, $b ) = self::parse_hex_color( (string) $config['text_color'] );
			$pdf->SetTextColor( $r, $g, $b );

			self::write_name_on_pdf( $pdf, $row, $config, $size_pt );

			$tmp = wp_tempnam( 'ton-card-' . $year );
			if ( ! $tmp ) {
				return new WP_Error( 'temp_file', __( 'Impossibile creare file temporaneo.', 'ton-italia-registration' ) );
			}

			$pdf->Output( 'F', $tmp );
			return $tmp;
		} catch ( Exception $e ) {
			return new WP_Error( 'pdf_error', $e->getMessage() );
		}
	}

	/**
	 * @param object $row    Registration row.
	 * @param int    $year   Year.
	 * @param string $path   Temp PDF path.
	 * @return int|WP_Error Attachment ID.
	 */
	private static function save_pdf_attachment( $row, $year, $path ) {
		TON_Reg_Documents::ensure_member_folder( $row );
		TON_Reg_Documents::ensure_subfolder( $row, self::SUBFOLDER );

		$filename = sprintf( 'Tessera-%d.pdf', (int) $year );
		$title    = sprintf(
			/* translators: 1: year, 2: full name */
			__( 'Tessera %1$d — %2$s', 'ton-italia-registration' ),
			(int) $year,
			$row->nome . ' ' . $row->cognome
		);

		return TON_Reg_Documents::sideload_file(
			$row,
			$path,
			$filename,
			$title,
			self::SUBFOLDER,
			array(
				'_ton_reg_registration_id' => (int) $row->id,
				'_ton_reg_document_type'   => 'tessera',
				'_ton_reg_card_year'       => (int) $year,
			)
		);
	}

	/**
	 * Write member name on the PDF, splitting onto two lines when needed.
	 *
	 * @param Fpdi                  $pdf     PDF instance.
	 * @param object                $row     Registration row.
	 * @param array<string,mixed>   $config  Layout config.
	 * @param float                 $size_pt Font size in points.
	 */
	private static function write_name_on_pdf( $pdf, $row, $config, $size_pt ) {
		$parts      = self::get_name_parts( $row, $config );
		$nome       = $parts['nome'];
		$cognome    = $parts['cognome'];
		$full_name  = trim( $nome . ( $cognome ? ' ' . $cognome : '' ) );
		$align      = in_array( $config['align'], array( 'L', 'C', 'R' ), true ) ? $config['align'] : 'C';
		$cell_width = max( 0, (float) $config['cell_width'] );
		$x          = (float) $config['name_x'];
		$y          = (float) $config['name_y'];

		$use_two_lines = false;
		if ( $cell_width > 0 && '' !== $cognome ) {
			$full_width = $pdf->GetStringWidth( $full_name );
			if ( $full_width > $cell_width ) {
				$use_two_lines = true;
			}
		}

		if ( $use_two_lines ) {
			$line_height = max( 4.0, $size_pt * 0.45 );
			$pdf->SetXY( $x, $y );
			$pdf->Cell( $cell_width, $line_height, $nome, 0, 2, $align );
			$pdf->SetX( $x );
			$pdf->Cell( $cell_width, $line_height, $cognome, 0, 0, $align );
			return;
		}

		$pdf->SetXY( $x, $y );
		$pdf->Cell( $cell_width, 0, $full_name, 0, 0, $align );
	}

	/**
	 * @param object              $row    Registration row.
	 * @param array<string,mixed> $config Layout config.
	 * @return array{nome:string,cognome:string}
	 */
	private static function get_name_parts( $row, $config ) {
		return array(
			'nome'    => self::format_name_part( (string) ( $row->nome ?? '' ), $config ),
			'cognome' => self::format_name_part( (string) ( $row->cognome ?? '' ), $config ),
		);
	}

	/**
	 * @param string              $text   Name part.
	 * @param array<string,mixed> $config Layout config.
	 * @return string
	 */
	private static function format_name_part( $text, $config ) {
		$text = trim( $text );
		if ( ! empty( $config['uppercase'] ) ) {
			if ( function_exists( 'mb_strtoupper' ) ) {
				$text = mb_strtoupper( $text, 'UTF-8' );
			} else {
				$text = strtoupper( $text );
			}
		}
		return $text;
	}

	/**
	 * @param object              $row    Registration row.
	 * @param array<string,mixed> $config Layout config.
	 * @return string
	 */
	private static function get_display_name( $row, $config ) {
		$parts = self::get_name_parts( $row, $config );
		return trim( $parts['nome'] . ' ' . $parts['cognome'] );
	}

	/**
	 * @param string $hex Hex color.
	 * @return array{0:int,1:int,2:int}
	 */
	private static function parse_hex_color( $hex ) {
		$hex = ltrim( trim( $hex ), '#' );
		if ( 3 === strlen( $hex ) ) {
			$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
		}
		if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) {
			return array( 0, 0, 0 );
		}
		return array(
			(int) hexdec( substr( $hex, 0, 2 ) ),
			(int) hexdec( substr( $hex, 2, 2 ) ),
			(int) hexdec( substr( $hex, 4, 2 ) ),
		);
	}

	/**
	 * @param string $family Font family.
	 * @return string
	 */
	private static function sanitize_font_family( $family ) {
		$allowed = array( 'Helvetica', 'Times', 'Courier', 'Arial' );
		return in_array( $family, $allowed, true ) ? ( 'Arial' === $family ? 'Helvetica' : $family ) : 'Helvetica';
	}

	/**
	 * @param string $style Font style.
	 * @return string
	 */
	private static function sanitize_font_style( $style ) {
		$allowed = array( '', 'B', 'I', 'BI', 'IB' );
		$style   = strtoupper( $style );
		if ( 'IB' === $style ) {
			$style = 'BI';
		}
		return in_array( $style, $allowed, true ) ? $style : '';
	}

	/**
	 * @param int $card_id Card ID.
	 * @return bool|WP_Error
	 */
	public static function current_user_can_download( $card_id ) {
		$card = TON_Reg_Membership_Cards::get( $card_id );
		if ( ! $card ) {
			return new WP_Error( 'not_found', __( 'Tessera non trovata.', 'ton-italia-registration' ) );
		}

		$row = TON_Reg_Database::get( (int) $card->registration_id );

		if ( self::current_user_can_manage_cards() ) {
			return true;
		}

		if ( ! is_user_logged_in() ) {
			return new WP_Error( 'login', __( 'Accedi per scaricare la tessera.', 'ton-italia-registration' ) );
		}

		$user_row = TON_Reg_Database::get_for_wp_user( wp_get_current_user() );
		if ( ! $user_row || (int) $user_row->id !== (int) $card->registration_id ) {
			return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) );
		}

		if ( ! $row || 'admitted' !== $row->status || ! empty( $row->anonymized_at ) ) {
			return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) );
		}

		return true;
	}

	/**
	 * @param int $card_id Card ID.
	 * @return void
	 */
	public static function stream_download( $card_id ) {
		$can = self::current_user_can_download( $card_id );
		if ( is_wp_error( $can ) ) {
			wp_die( esc_html( $can->get_error_message() ), 403 );
		}

		$card = TON_Reg_Membership_Cards::get( $card_id );
		$path = get_attached_file( (int) $card->attachment_id );

		if ( ! $path || ! is_readable( $path ) ) {
			wp_die( esc_html__( 'File non trovato.', 'ton-italia-registration' ), 404 );
		}

		$filename = basename( $path );
		nocache_headers();
		header( 'Content-Type: application/pdf' );
		header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $filename ) . '"' );
		header( 'Content-Length: ' . (string) filesize( $path ) );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
		readfile( $path );
		exit;
	}

	/**
	 * @param string $context Generation context key.
	 * @return string
	 */
	public static function context_label( $context ) {
		$labels = array(
			self::CONTEXT_ADMISSION => __( 'Ammissione', 'ton-italia-registration' ),
			self::CONTEXT_BATCH     => __( 'Batch annuale', 'ton-italia-registration' ),
			self::CONTEXT_MANUAL    => __( 'Manuale', 'ton-italia-registration' ),
		);
		return $labels[ $context ] ?? $context;
	}
}