<?php
/**
 * BitForgeNet Guest Tickets - Minimal, dependency-free PDF writer.
 *
 * Produces a valid PDF 1.4 document whose pages are full-bleed JPEG images at an
 * exact physical size. This is deliberately narrow in scope: the ticket artwork is
 * already rasterised at 300 dpi by the GD compositor, so the PDF only needs to place
 * it at the right physical dimensions. That keeps the plugin free of the multi-megabyte
 * PDF libraries that break on shared hosting.
 *
 * @package BitForgeNet_Guest_Tickets
 * @since   1.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Image-based PDF document builder.
 */
class BFNET_PDF {

	/**
	 * Points per millimetre.
	 */
	const PT_PER_MM = 2.834645669;

	/**
	 * Pages awaiting output.
	 *
	 * @var array
	 */
	private $pages = array();

	/**
	 * Document metadata.
	 *
	 * @var array
	 */
	private $meta = array(
		'title'    => 'Ticket',
		'author'   => 'BitForgeNet Guest Tickets',
		'subject'  => '',
		'creator'  => 'BitForgeNet Guest Tickets for WordPress',
		'keywords' => '',
	);

	/**
	 * Set document metadata.
	 *
	 * @param array $meta Metadata values.
	 * @return void
	 */
	public function set_meta( array $meta ) {
		$this->meta = array_merge( $this->meta, array_map( 'strval', $meta ) );
	}

	/**
	 * Append a page rendered from raw JPEG data.
	 *
	 * @param string $jpeg      Binary JPEG data.
	 * @param int    $px_width  Image pixel width.
	 * @param int    $px_height Image pixel height.
	 * @param float  $mm_width  Physical page width in millimetres.
	 * @param float  $mm_height Physical page height in millimetres.
	 * @return void
	 */
	public function add_jpeg_page( $jpeg, $px_width, $px_height, $mm_width, $mm_height ) {
		$this->pages[] = array(
			'data'   => $jpeg,
			'px_w'   => (int) $px_width,
			'px_h'   => (int) $px_height,
			'pt_w'   => round( $mm_width * self::PT_PER_MM, 4 ),
			'pt_h'   => round( $mm_height * self::PT_PER_MM, 4 ),
		);
	}

	/**
	 * Number of queued pages.
	 *
	 * @return int
	 */
	public function page_count() {
		return count( $this->pages );
	}

	/**
	 * Build the PDF byte stream.
	 *
	 * @return string
	 * @throws RuntimeException When no page has been added.
	 */
	public function render() {
		if ( empty( $this->pages ) ) {
			throw new RuntimeException( 'Cannot render a PDF with no pages.' );
		}

		$objects = array();
		$count   = count( $this->pages );

		// Object numbering:
		// 1 = catalog, 2 = pages tree, 3 = info.
		// Then per page i (0-based): 4+i*3 = page, 5+i*3 = image, 6+i*3 = content.
		$kids = array();
		for ( $i = 0; $i < $count; $i++ ) {
			$kids[] = ( 4 + ( $i * 3 ) ) . ' 0 R';
		}

		$objects[1] = "<< /Type /Catalog /Pages 2 0 R >>";
		$objects[2] = '<< /Type /Pages /Kids [' . implode( ' ', $kids ) . '] /Count ' . $count . ' >>';
		$objects[3] = '<< /Title ' . $this->text( $this->meta['title'] )
			. ' /Author ' . $this->text( $this->meta['author'] )
			. ' /Subject ' . $this->text( $this->meta['subject'] )
			. ' /Keywords ' . $this->text( $this->meta['keywords'] )
			. ' /Creator ' . $this->text( $this->meta['creator'] )
			. ' /Producer ' . $this->text( 'BitForgeNet Guest Tickets ' . ( defined( 'BFNET_VERSION' ) ? BFNET_VERSION : '1.0' ) )
			. ' /CreationDate ' . $this->text( 'D:' . gmdate( 'YmdHis' ) . 'Z' )
			. ' >>';

		foreach ( $this->pages as $i => $page ) {
			$page_obj    = 4 + ( $i * 3 );
			$image_obj   = $page_obj + 1;
			$content_obj = $page_obj + 2;

			$objects[ $page_obj ] = '<< /Type /Page /Parent 2 0 R '
				. '/MediaBox [0 0 ' . $page['pt_w'] . ' ' . $page['pt_h'] . '] '
				. '/Resources << /XObject << /Im0 ' . $image_obj . ' 0 R >> /ProcSet [/PDF /ImageC] >> '
				. '/Contents ' . $content_obj . ' 0 R >>';

			$objects[ $image_obj ] = array(
				'dict'   => '<< /Type /XObject /Subtype /Image /Name /Im0 '
					. '/Width ' . $page['px_w'] . ' /Height ' . $page['px_h'] . ' '
					. '/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode '
					. '/Length ' . strlen( $page['data'] ) . ' >>',
				'stream' => $page['data'],
			);

			$content = 'q ' . $page['pt_w'] . ' 0 0 ' . $page['pt_h'] . ' 0 0 cm /Im0 Do Q';

			$objects[ $content_obj ] = array(
				'dict'   => '<< /Length ' . strlen( $content ) . ' >>',
				'stream' => $content,
			);
		}

		ksort( $objects );

		$pdf     = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
		$offsets = array();

		foreach ( $objects as $number => $object ) {
			$offsets[ $number ] = strlen( $pdf );
			$pdf               .= $number . " 0 obj\n";
			if ( is_array( $object ) ) {
				$pdf .= $object['dict'] . "\nstream\n" . $object['stream'] . "\nendstream\n";
			} else {
				$pdf .= $object . "\n";
			}
			$pdf .= "endobj\n";
		}

		$max        = max( array_keys( $objects ) );
		$xref_start = strlen( $pdf );

		$pdf .= "xref\n0 " . ( $max + 1 ) . "\n";
		$pdf .= "0000000000 65535 f \n";
		for ( $n = 1; $n <= $max; $n++ ) {
			$offset = isset( $offsets[ $n ] ) ? $offsets[ $n ] : 0;
			$pdf   .= sprintf( "%010d 00000 n \n", $offset );
		}

		$pdf .= "trailer\n<< /Size " . ( $max + 1 ) . " /Root 1 0 R /Info 3 0 R /ID [<"
			. md5( $this->meta['title'] . microtime( true ) ) . "><"
			. md5( $this->meta['title'] . microtime( true ) ) . ">] >>\n";
		$pdf .= "startxref\n" . $xref_start . "\n%%EOF\n";

		return $pdf;
	}

	/**
	 * Write the PDF to disk.
	 *
	 * @param string $path Absolute destination path.
	 * @return bool
	 */
	public function save( $path ) {
		$bytes = $this->render();

		$dir = dirname( $path );
		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
		}

		$written = file_put_contents( $path, $bytes ); // phpcs:ignore WordPress.WP.AlternativeFunctions

		if ( false !== $written ) {
			@chmod( $path, 0640 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- WP_Filesystem has no chmod for a file we just wrote.
			return true;
		}

		return false;
	}

	/**
	 * Escape a string for a PDF text object.
	 *
	 * @param string $value Raw value.
	 * @return string
	 */
	private function text( $value ) {
		$value = (string) $value;
		// UTF-16BE with BOM keeps accents intact in PDF readers.
		$utf16 = "\xFE\xFF" . mb_convert_encoding( $value, 'UTF-16BE', 'UTF-8' );
		$utf16 = str_replace( array( '\\', '(', ')' ), array( '\\\\', '\\(', '\\)' ), $utf16 );
		return '(' . $utf16 . ')';
	}
}
