<?php
/**
 * SSRF-safe Dompdf wrapper: HTML string -> PDF bytes (INV-01).
 *
 * A thin adapter over the Strauss-prefixed Dompdf engine bundled in 07-02
 * (WrenchPress\Vendor\Dompdf\Dompdf, loaded from vendor-prefixed/autoload.php).
 * The engine is referenced ONLY by its prefixed namespace so a second plugin
 * bundling its own Dompdf cannot collide.
 *
 * Hardening (RESEARCH Pitfall 5, threat T-07-06-01): remote resource fetching
 * is disabled (isRemoteEnabled = false) and file access is confined to the
 * uploads directory (chroot). The optional logo is resolved to a LOCAL path via
 * get_attached_file() and confirmed to live under the uploads dir; a URL is
 * never used, and the local path exists only so the template can read the bytes
 * and inline them as a single base64 data: URI (B2). is_available() lets callers
 * degrade to the browser-print view when the runtime vendor is absent
 * (threat T-07-06-04) instead of fataling; to_pdf() likewise catches any render
 * throwable and returns '' so a Dompdf failure degrades to print rather than a
 * fatal (threat T-07-07-05).
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

use WrenchPress\Vendor\Dompdf\Dompdf;
use WrenchPress\Vendor\Dompdf\Options;

/**
 * Class WrenchPress_Pdf_Renderer
 */
class WrenchPress_Pdf_Renderer {

	/**
	 * Whether the bundled PDF engine is loadable in this runtime.
	 *
	 * Guards the PDF output path: a release built without the prefixed vendor
	 * tree (or a host missing a hard Dompdf extension) disables PDF while leaving
	 * the browser-print view working.
	 *
	 * @return bool True when the prefixed Dompdf class is available.
	 */
	public static function is_available() {
		return class_exists( 'WrenchPress\\Vendor\\Dompdf\\Dompdf' );
	}

	/**
	 * Render a complete HTML document string into PDF bytes.
	 *
	 * The $html argument is the exact string produced by
	 * WrenchPress_Document_Template::render(), so the PDF and the browser-print
	 * view rasterize identical content (D-01). Remote content is disabled and
	 * file access is chrooted to the uploads directory.
	 *
	 * @param string $html Complete standalone HTML document.
	 * @return string Binary PDF bytes (begins with %PDF-), or '' when the engine
	 *                is unavailable or a render throwable is caught.
	 */
	public static function to_pdf( $html ) {
		if ( ! self::is_available() ) {
			return '';
		}

		$uploads = wp_upload_dir();
		$basedir = (string) $uploads['basedir'];

		$options = new Options();
		$options->set( 'isRemoteEnabled', false );
		if ( '' !== $basedir ) {
			$options->set( 'chroot', $basedir );
		}
		$options->set( 'defaultPaperSize', 'letter' );

		// Confine Dompdf's writable state (parsed font metric cache, e.g.
		// Times-Roman.afm.json, plus any temp files) to an uploads-owned directory.
		// Left at its default, Dompdf writes that cache into the shipped, often
		// read-only vendor-prefixed/dompdf tree; the bundled core fonts are still
		// READ from lib/fonts (they resolve via the engine root, not these options),
		// so pointing fontDir/fontCache/tempDir at uploads keeps the vendor tree
		// pristine without breaking rendering.
		$cache_dir = self::cache_dir();
		if ( '' !== $cache_dir ) {
			$options->set( 'fontDir', $cache_dir );
			$options->set( 'fontCache', $cache_dir );
			$options->set( 'tempDir', $cache_dir );
		}

		// Dompdf can throw on font-metric failures, malformed markup, or memory
		// and paper edge cases. Catch any throwable and return '' so the caller
		// degrades cleanly to the browser-print view (T-07-07-05) instead of
		// fataling, matching the degradation contract is_available() documents.
		try {
			$dompdf = new Dompdf( $options );
			$dompdf->loadHtml( (string) $html );
			$dompdf->setPaper( 'letter', 'portrait' );
			$dompdf->render();

			return (string) $dompdf->output();
		} catch ( \Throwable $e ) {
			return '';
		}
	}

	/**
	 * Ensure and return a writable, uploads-confined directory for Dompdf's font
	 * cache and temp files.
	 *
	 * The directory lives under the uploads basedir (inside the render chroot) so
	 * Dompdf never writes into the shipped vendor-prefixed/ tree. A silence
	 * index.html is dropped once to prevent directory listing. Returns '' when the
	 * uploads directory is unavailable or the directory cannot be created, in which
	 * case the caller leaves Dompdf's defaults untouched.
	 *
	 * @return string Absolute directory path with no trailing slash, or ''.
	 */
	private static function cache_dir() {
		$uploads = wp_upload_dir();

		if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) {
			return '';
		}

		$dir = rtrim( (string) $uploads['basedir'], '/\\' ) . '/wrenchpress-pdf-cache';

		if ( ! wp_mkdir_p( $dir ) ) {
			return '';
		}

		$index = $dir . '/index.html';
		if ( ! file_exists( $index ) ) {
			// Best-effort directory-listing guard; a failure here is non-fatal.
			@file_put_contents( $index, '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort silence file; failure is tolerated.
		}

		return $dir;
	}

	/**
	 * Resolve a logo attachment id to a LOCAL, uploads-confined file path.
	 *
	 * Uses get_attached_file() to read a local path (never the attachment URL)
	 * and returns the path only when the file exists and resolves inside uploads,
	 * so it is safe to read under the Dompdf chroot. The path is consumed solely
	 * to read the file bytes for the template's base64 data: URI; it is never
	 * emitted as an <img src>.
	 *
	 * @param int $attachment_id Media-library attachment id.
	 * @return string Absolute local path, or '' when unresolved or out of bounds.
	 */
	public static function logo_path( $attachment_id ) {
		$attachment_id = absint( $attachment_id );
		if ( 0 === $attachment_id ) {
			return '';
		}

		$path = get_attached_file( $attachment_id );
		if ( ! is_string( $path ) || '' === $path || ! file_exists( $path ) ) {
			return '';
		}

		$uploads = wp_upload_dir();
		$basedir = (string) $uploads['basedir'];
		if ( '' === $basedir ) {
			return '';
		}

		$real_path = realpath( $path );
		$real_base = realpath( $basedir );
		if ( false === $real_path || false === $real_base ) {
			return '';
		}

		if ( $real_path !== $real_base && 0 !== strpos( $real_path, $real_base . DIRECTORY_SEPARATOR ) ) {
			return '';
		}

		return $real_path;
	}
}
