<?php
/**
 * PDF generator using mPDF.
 *
 * @since 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class ZINV_PDF_Generator {

	/**
	 * DejaVu Sans TTF files required for PDF generation (shipped in release builds).
	 *
	 * @var string[]
	 */
	private const REQUIRED_MPDF_FONTS = [
		'DejaVuSans.ttf',
		'DejaVuSans-Bold.ttf',
		'Inter-Regular.ttf',
		'Inter-Medium.ttf',
		'Inter-SemiBold.ttf',
		'Inter-Bold.ttf',
		'Inter-ExtraBold.ttf',
	];

	/**
	 * Whether all required mPDF font files are present and readable.
	 *
	 * @return bool
	 */
	public static function fonts_ready(): bool {
		return null === self::missing_fonts_message();
	}

	/**
	 * Human-readable error when PDF fonts are missing, or null when OK.
	 *
	 * @return string|null
	 */
	public static function missing_fonts_message(): ?string {
		$missing = [];

		foreach ( self::REQUIRED_MPDF_FONTS as $file ) {
			if ( null === self::resolve_font_path( $file ) ) {
				$missing[] = $file;
			}
		}

		if ( empty( $missing ) ) {
			return null;
		}

		return sprintf(
			/* translators: %s: comma-separated list of missing .ttf filenames */
			__( 'PDF fonts are missing (%s). Please reinstall the plugin from a complete release build or contact support.', 'zoventic-invoices' ),
			implode( ', ', $missing )
		);
	}

	/**
	 * Directories searched for required TrueType fonts (bundled first, then vendor).
	 *
	 * Release builds copy DejaVuSans*.ttf into includes/pdf/fonts/ so PDFs work
	 * even after vendor/mpdf/mpdf/ttfonts is pruned from the zip.
	 *
	 * @return string[]
	 */
	private static function mpdf_font_dirs(): array {
		$dirs = [];

		$bundled = ZINV_PLUGIN_DIR . 'includes/pdf/fonts';
		if ( is_dir( $bundled ) ) {
			$dirs[] = $bundled;
		}

		$vendor = ZINV_PLUGIN_DIR . 'vendor/mpdf/mpdf/ttfonts';
		if ( is_dir( $vendor ) ) {
			$dirs[] = $vendor;
		}

		return $dirs;
	}

	/**
	 * Resolve the directory that contains a required font file.
	 *
	 * @param string $filename TTF basename.
	 * @return string|null
	 */
	private static function resolve_font_path( string $filename ): ?string {
		foreach ( self::mpdf_font_dirs() as $dir ) {
			$path = $dir . '/' . $filename;
			if ( is_readable( $path ) ) {
				return $path;
			}
		}
		return null;
	}

	/**
	 * @throws \Exception When required font files are absent.
	 */
	private static function assert_fonts_ready(): void {
		$message = self::missing_fonts_message();
		if ( null !== $message ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new \Exception( $message );
		}
	}

	/**
	 * Writable temp directory for mPDF (outside the plugin tree).
	 *
	 * @return string
	 */
	private static function mpdf_temp_dir(): string {
		$dir = self::private_base_dir() . '/mpdf-tmp';
		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
		}
		self::protect_cache_dir( $dir );
		return $dir;
	}

	/**
	 * Build an mPDF instance with margins appropriate for the template.
	 *
	 * Standard templates get a 12mm page margin. Full-bleed templates (a colored
	 * bar/band that must reach the page edge) get a 0mm margin and inset their
	 * content via CSS padding instead. Templates must NOT set page margins via a
	 * CSS `@page` rule — that path is broken in mPDF and overflows content into
	 * endless blank pages. Margins belong here, in the constructor, only.
	 *
	 * @param string $template Sanitized template slug.
	 * @return \Mpdf\Mpdf
	 */
	private static function make_mpdf( string $template ): \Mpdf\Mpdf {
		if ( ! class_exists( 'Mpdf\Mpdf' ) && file_exists( ZINV_PLUGIN_DIR . 'vendor/autoload.php' ) ) {
			require_once ZINV_PLUGIN_DIR . 'vendor/autoload.php';
		}
		self::assert_fonts_ready();

		/**
		 * Filter the list of full-bleed template slugs (zero page margin).
		 *
		 * @param string[] $slugs Template slugs that render edge-to-edge.
		 */
		$full_bleed = apply_filters( 'zoventic_invoices_pdf_full_bleed_templates', [] );

		// Full-bleed templates render edge-to-edge and inset content via CSS, so
		// they must keep a 0mm constructor margin. Standard templates take the
		// margin from the Template Builder "Page Margins" slider.
		$margin = in_array( $template, (array) $full_bleed, true )
			? 0
			: ZINV_Template_Style::page_margin_mm();

		$defaultConfig     = ( new \Mpdf\Config\ConfigVariables() )->getDefaults();
		$fontDirs          = array_merge( $defaultConfig['fontDir'], self::mpdf_font_dirs() );
		$defaultFontConfig = ( new \Mpdf\Config\FontVariables() )->getDefaults();
		$fontData          = $defaultFontConfig['fontdata'];

		return new \Mpdf\Mpdf( [
			'mode'          => 'utf-8',
			'format'        => 'A4',
			'margin_left'   => $margin,
			'margin_right'  => $margin,
			'margin_top'    => $margin,
			'margin_bottom' => $margin,
			'fontDir'       => $fontDirs,
			'fontdata'      => $fontData + [
				'inter' => [
					'R' => 'Inter-Regular.ttf',
					'B' => 'Inter-Bold.ttf',
				],
				'inter-medium' => [
					'R'  => 'Inter-Medium.ttf',
					'B'  => 'Inter-Bold.ttf',
				],
				'inter-semibold' => [
					'R'  => 'Inter-SemiBold.ttf',
					'B'  => 'Inter-ExtraBold.ttf',
				],
				'inter-bold' => [
					'R'  => 'Inter-Bold.ttf',
					'B'  => 'Inter-ExtraBold.ttf',
				],
				'inter-extrabold' => [
					'R'  => 'Inter-ExtraBold.ttf',
					'B'  => 'Inter-ExtraBold.ttf',
				],
			],
			'tempDir'       => self::mpdf_temp_dir(),
			'default_font'  => 'inter',
		] );
	}

	private static function apply_watermark( \Mpdf\Mpdf $mpdf, array $doc ): void {
		$enable_watermark = (bool) ZINV_Settings::get( 'enable_watermark', false );
		if ( ! $enable_watermark ) {
			return;
		}

		$watermark_type   = (string) ZINV_Settings::get( 'watermark_type', 'SAMPLE' );
		$watermark_custom = (string) ZINV_Settings::get( 'watermark_custom_text', '' );

		$text = '';
		if ( 'CUSTOM' === $watermark_type ) {
			$text = ! empty( $watermark_custom ) ? $watermark_custom : 'SAMPLE';
		} elseif ( 'PAID' === $watermark_type ) {
			$text = 'PAID';
		} elseif ( in_array( $watermark_type, [ 'DRAFT', 'VOID', 'OVERDUE', 'SAMPLE' ], true ) ) {
			$text = $watermark_type;
		} else {
			$text = ! empty( $watermark_type ) ? $watermark_type : 'SAMPLE';
		}

		if ( ! empty( $text ) ) {
			$alpha = 0.055;
			$mpdf->SetWatermarkText( $text, $alpha );
			$mpdf->showWatermarkText  = true;
			$mpdf->watermarkTextAlpha = $alpha;
			$mpdf->watermark_font     = 'dejavusans';
		}
	}

	public static function generate( int $invoice_id, string $template = 'clean-sheet' ): string {
		try {
			$invoice_crud = new ZINV_Invoice();
			$invoice      = $invoice_crud->get( $invoice_id );

			if ( ! $invoice ) {
				throw new \Exception( 'Invoice not found.' );
			}

			$client_crud = new ZINV_Client();
			$client      = $client_crud->get( (int) $invoice['client_id'] );

			$html = self::render_template( $template, $invoice, $client );
		} catch ( \Exception $e ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log( 'Zoventic Invoices PDF render_template error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine() );
			throw $e;
		}

		$cache_dir = self::cache_dir();

		if ( ! is_dir( $cache_dir ) ) {
			wp_mkdir_p( $cache_dir );
			self::protect_cache_dir( $cache_dir );
		}

		$filename = self::cache_filename( $invoice_id, $invoice['invoice_number'] );
		$filepath = $cache_dir . '/' . $filename;
		// Race guard: write to a unique temp file then atomic-rename. Two
		// concurrent generate() calls for the same invoice can no longer
		// corrupt the cached PDF mid-write.
		$tmp_path = $filepath . '.tmp-' . bin2hex( random_bytes( 6 ) );

		$mpdf = self::make_mpdf( $template );
		self::apply_watermark( $mpdf, $invoice );
		try {
			/* translators: %s: invoice number */
			$mpdf->SetTitle( sprintf( __( 'Invoice %s', 'zoventic-invoices' ), $invoice['invoice_number'] ) );
			$mpdf->WriteHTML( $html );
			$mpdf->Output( $tmp_path, \Mpdf\Output\Destination::FILE );
		} catch ( \Exception $e ) {
			wp_delete_file( $tmp_path );
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log( 'Zoventic Invoices mPDF error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine() );
			throw $e;
		}

		// Atomic rename on the same filesystem — required so a concurrent PDF
		// generation never serves a half-written file. WP_Filesystem::move() is
		// copy-then-delete (non-atomic), so the native rename() is used here on
		// purpose. Cleanup of the temp file uses wp_delete_file().
		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
		$renamed = rename( $tmp_path, $filepath );
		if ( ! $renamed ) {
			wp_delete_file( $tmp_path );
			throw new \Exception( 'Failed to finalize PDF cache file.' );
		}

		$relative_path = 'zoventic-invoices-private/pdfs/' . $filename;

		$invoice_crud->set_pdf_path( $invoice_id, $relative_path );

		return $filepath;
	}

	public static function stream( int $invoice_id, string $template = 'clean-sheet', string $disposition = 'inline' ): void {
		$invoice_crud = new ZINV_Invoice();
		$invoice      = $invoice_crud->get( $invoice_id );

		if ( ! $invoice ) {
			throw new \Exception( 'Invoice not found.' );
		}

		$base_dir       = self::private_base_dir();
		$base_real      = realpath( $base_dir );
		$expected_name  = self::cache_filename( $invoice_id, $invoice['invoice_number'] );
		$expected_path  = $base_dir . '/pdfs/' . $expected_name;

		// Use cached PDF if pdf_path matches expected secure pattern and resolves under base.
		// Require non-empty $base_real — if base dir doesn't exist yet, strpos($real, '') === 0
		// would TRUE for ANY path (silent path-traversal).
		if (
			! empty( $invoice['pdf_path'] )
			&& ( 0 === strpos( $invoice['pdf_path'], 'zoventic-invoices-private/pdfs/' ) )
			&& basename( $invoice['pdf_path'] ) === $expected_name
			&& file_exists( $expected_path )
		) {
			$real = realpath( $expected_path );
			if ( $real && $base_real && '' !== $base_real && 0 === strpos( $real, $base_real . DIRECTORY_SEPARATOR ) ) {
				self::send_pdf_headers( $invoice['invoice_number'], filesize( $real ), true, $disposition );
				readfile( $real ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
				if ( self::is_testing() ) {
					return;
				}
				exit;
			}
		}

		$filepath = self::generate( $invoice_id, $template );

		self::send_pdf_headers( $invoice['invoice_number'], filesize( $filepath ), true, $disposition );
		readfile( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
		if ( self::is_testing() ) {
			return;
		}
		exit;
	}

	public static function generate_estimate( int $estimate_id, string $template = 'clean-sheet' ): string {
		$estimate_crud = new ZINV_Estimate();
		$estimate      = $estimate_crud->get( $estimate_id );

		if ( ! $estimate ) {
			throw new \Exception( 'Estimate not found.' );
		}

		// Map estimate to invoice-compatible shape for template reuse.
		$doc = [
			'document_type'  => 'estimate',
			'document_title' => ! empty( $estimate['document_title'] ) ? $estimate['document_title'] : __( 'ESTIMATE', 'zoventic-invoices' ),
			'invoice_number' => $estimate['estimate_number'],
			'issue_date'     => $estimate['issue_date'],
			'due_date'       => $estimate['expiry_date'] ?? null,
			'total'          => $estimate['total'],
			'subtotal'       => $estimate['subtotal'],
			'tax_total'      => $estimate['tax_total'],
			'discount_type'  => $estimate['discount_type'] ?? null,
			'discount_value' => $estimate['discount_value'] ?? 0,
			'discount_total' => $estimate['discount_total'] ?? 0,
			'currency'       => $estimate['currency'],
			'template'       => $estimate['template'] ?? 'clean-sheet',
			'notes'          => $estimate['notes'] ?? '',
			'terms'          => $estimate['terms'] ?? '',
			'footer_text'    => $estimate['footer_text'] ?? '',
			'line_items'     => $estimate['line_items'] ?? [],
			'client_id'      => $estimate['client_id'],
			'status'         => $estimate['status'] ?? 'draft',
		];

		$client_crud = new ZINV_Client();
		$client      = $client_crud->get( (int) $estimate['client_id'] );

		$html = self::render_template( $template, $doc, $client );

		$cache_dir = self::cache_dir();
		if ( ! is_dir( $cache_dir ) ) {
			wp_mkdir_p( $cache_dir );
			self::protect_cache_dir( $cache_dir );
		}

		$filename = self::cache_filename( $estimate_id, 'est-' . $estimate['estimate_number'] );
		$filepath = $cache_dir . '/' . $filename;
		$tmp_path = $filepath . '.tmp-' . bin2hex( random_bytes( 6 ) );

		$mpdf = self::make_mpdf( $template );
		self::apply_watermark( $mpdf, $doc );
		try {
			/* translators: %s: estimate number */
			$mpdf->SetTitle( sprintf( __( 'Estimate %s', 'zoventic-invoices' ), $estimate['estimate_number'] ) );
			$mpdf->WriteHTML( $html );
			$mpdf->Output( $tmp_path, \Mpdf\Output\Destination::FILE );
		} catch ( \Exception $e ) {
			wp_delete_file( $tmp_path );
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log( 'Zoventic Invoices mPDF error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine() );
			throw $e;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
		$renamed = rename( $tmp_path, $filepath );
		if ( ! $renamed ) {
			wp_delete_file( $tmp_path );
			throw new \Exception( 'Failed to finalize PDF cache file.' );
		}

		return $filepath;
	}

	public static function stream_estimate( int $estimate_id, string $template = 'clean-sheet', string $disposition = 'inline' ): void {
		$estimate_crud = new ZINV_Estimate();
		$estimate      = $estimate_crud->get( $estimate_id );

		if ( ! $estimate ) {
			throw new \Exception( 'Estimate not found.' );
		}

		$filepath = self::generate_estimate( $estimate_id, $template );

		self::send_pdf_headers( $estimate['estimate_number'], filesize( $filepath ), true, $disposition );
		readfile( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
		if ( self::is_testing() ) {
			return;
		}
		exit;
	}

	private static function render_template( string $template, array $invoice, ?array $client ): string {
		// Allowlist the template slug to PHP-safe characters before path-building
		// so 'template' values like '../../wp-config' can't escape the templates dir.
		$slug          = preg_replace( '/[^A-Za-z0-9_-]/', '', $template ) ?: 'clean-sheet';
		$default_file  = ZINV_PLUGIN_DIR . "includes/pdf/templates/{$slug}.php";
		$template_file = apply_filters( 'zoventic_invoices_pdf_template_file', $default_file, $slug );

		// Defense-in-depth: filter consumers (e.g. the Pro plugin) may return
		// paths outside the free plugin. Confine the resolved file to a known
		// set of trusted template directories — the free plugin's own dir plus
		// any registered via the filter below — so a hostile/buggy consumer
		// can't include arbitrary PHP. Falls back to clean-sheet otherwise.
		$allowed_dirs = [ ZINV_PLUGIN_DIR . 'includes/pdf/templates' ];
		/**
		 * Filter the directories PDF templates may be loaded from.
		 *
		 * @param string[] $allowed_dirs Absolute paths of trusted template dirs.
		 */
		$allowed_dirs = apply_filters( 'zoventic_invoices_pdf_template_dirs', $allowed_dirs );

		$real_target   = is_string( $template_file ) ? realpath( $template_file ) : false;
		$template_file = ZINV_PLUGIN_DIR . 'includes/pdf/templates/clean-sheet.php';

		if ( $real_target && is_file( $real_target ) ) {
			foreach ( $allowed_dirs as $dir ) {
				$real_dir = realpath( $dir );
				if ( $real_dir && 0 === strpos( $real_target, $real_dir . DIRECTORY_SEPARATOR ) ) {
					$template_file = $real_target;
					break;
				}
			}
		}

		ob_start();
		require $template_file; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_include_require
		return ob_get_clean();
	}

	public static function delete_cached_pdf( int $invoice_id ): void {
		$invoice_crud = new ZINV_Invoice();
		$invoice      = $invoice_crud->get( $invoice_id );

		if ( ! $invoice || empty( $invoice['pdf_path'] ) ) {
			return;
		}

		// Only delete files inside the private dir with expected name format.
		$base_dir = self::private_base_dir();
		$path     = $base_dir . '/' . ltrim( str_replace( 'zoventic-invoices-private/', '', $invoice['pdf_path'] ), '/' );
		$real     = realpath( $path );
		$base_real = realpath( $base_dir );

		// Require non-empty $base_real — empty base would match any path via strpos.
		if ( $real && $base_real && '' !== $base_real && 0 === strpos( $real, $base_real . DIRECTORY_SEPARATOR ) && file_exists( $real ) ) {
			wp_delete_file( $real );
		}

		$invoice_crud->clear_pdf_path( $invoice_id );
	}

	/**
	 * Delete all cached PDF files and clear PDF paths in database when settings are updated.
	 */
	public static function delete_all_cached_pdfs(): void {
		$cache_dir = self::cache_dir();
		if ( is_dir( $cache_dir ) ) {
			foreach ( (array) glob( $cache_dir . '/*.pdf' ) as $file ) {
				if ( is_file( $file ) ) {
					wp_delete_file( $file );
				}
			}
		}

		global $wpdb;
		if ( class_exists( 'ZINV_Database' ) ) {
			$table = ( new ZINV_Database( 'zinv_invoices' ) )->get_table_name();
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			$wpdb->query( "UPDATE `{$table}` SET `pdf_path` = NULL" );
		}
	}


	public static function format_money( string $amount, string $currency = 'USD' ): string {
		$symbols = [
			'USD' => '$',
			'EUR' => '€',
			'GBP' => '£',
			'CAD' => '$',
			'AUD' => '$',
		];
		$symbol = $symbols[ $currency ] ?? $currency . ' ';

		// Reject non-numeric defensively so number formatter doesn't get garbage.
		if ( ! is_numeric( $amount ) ) {
			$amount = '0';
		}

		// Normalize to 2dp via BCMath (precision-safe). Then split integer +
		// fractional parts and thousand-separate the integer ourselves — never
		// (float)-cast, which corrupts amounts > ~2^53 / ~9e15.
		$normalized = bcadd( $amount, '0', 2 );
		$negative   = bccomp( $normalized, '0', 2 ) < 0;
		$abs        = $negative ? bcmul( $normalized, '-1', 2 ) : $normalized;

		$parts    = explode( '.', $abs );
		$int_part = $parts[0];
		$frac     = isset( $parts[1] ) ? str_pad( $parts[1], 2, '0', STR_PAD_RIGHT ) : '00';

		// Manual thousand separators (avoid float).
		$with_commas = strrev( implode( ',', str_split( strrev( $int_part ), 3 ) ) );

		return ( $negative ? '-' : '' ) . $symbol . $with_commas . '.' . $frac;
	}

	// -------------------------------------------------------------------------
	// Template allowlist — single source of truth for valid template slugs
	// -------------------------------------------------------------------------

	/**
	 * List the template slugs available on this install.
	 *
	 * Scans every trusted template directory for `<slug>.php` files. The set
	 * is Pro-aware automatically: the Pro plugin registers its templates dir
	 * via the `zoventic_invoices_pdf_template_dirs` filter only while Pro is active,
	 * so Pro-only templates drop out of this list on a free site.
	 *
	 * @return string[] Sorted, de-duplicated list of valid template slugs.
	 */
	public static function available_templates(): array {
		$allowed_dirs = [ ZINV_PLUGIN_DIR . 'includes/pdf/templates' ];
		/** This filter is documented in render_template(). */
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
		$allowed_dirs = apply_filters( 'zoventic_invoices_pdf_template_dirs', $allowed_dirs );

		$slugs = [];
		foreach ( (array) $allowed_dirs as $dir ) {
			if ( ! is_string( $dir ) || ! is_dir( $dir ) ) {
				continue;
			}
			foreach ( (array) glob( rtrim( $dir, '/\\' ) . '/*.php' ) as $file ) {
				$slug = basename( (string) $file, '.php' );
				// Defensive: only keep PHP-safe slugs (matches render_template()).
				if ( '' !== $slug && $slug === preg_replace( '/[^A-Za-z0-9_-]/', '', $slug ) ) {
					$slugs[ $slug ] = true;
				}
			}
		}

		// Guarantee the built-in default is always present.
		$slugs['clean-sheet'] = true;

		$slugs = array_keys( $slugs );
		sort( $slugs );

		return $slugs;
	}

	/**
	 * Whether a template slug is valid (exists + permitted) on this install.
	 *
	 * @param string $template Template slug to check.
	 * @return bool
	 */
	public static function is_valid_template( string $template ): bool {
		return in_array( $template, self::available_templates(), true );
	}

	// -------------------------------------------------------------------------
	// Private helpers — cache location + headers
	// -------------------------------------------------------------------------

	/**
	 * Private storage directory inside uploads/ — keeps PDFs off the public web.
	 * Uses wp-content/uploads/zoventic-invoices-private/ which is protected via .htaccess + index.php.
	 */
	private static function private_base_dir(): string {
		$upload_dir = wp_upload_dir();
		return $upload_dir['basedir'] . '/zoventic-invoices-private';
	}

	private static function cache_dir(): string {
		return self::private_base_dir() . '/pdfs';
	}

	/**
	 * Random-token filename per invoice. Prevents URL enumeration even if the
	 * private dir ever gets misconfigured to serve files. Token persisted via
	 * the file path itself.
	 */
	private static function cache_filename( int $invoice_id, string $invoice_number ): string {
		$slug = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $invoice_number );
		$token = substr( hash_hmac( 'sha256', (string) $invoice_id . '|' . $slug, wp_salt( 'auth' ) ), 0, 16 );
		return sprintf( '%s-%s.pdf', $slug ?: ( 'invoice-' . $invoice_id ), $token );
	}

	private static function is_testing(): bool {
		return ( defined( 'ZINV_DOING_TESTS' ) && ZINV_DOING_TESTS ) || ( defined( 'ZINV_TESTING' ) && ZINV_TESTING );
	}

	private static function send_pdf_headers( string $invoice_number, int $size, bool $send_length = true, string $disposition = 'inline' ): void {
		if ( self::is_testing() ) {
			return;
		}

		// Discard any open output buffers (WP REST opens one before dispatch) so
		// queued markup can't precede the PDF bytes and the JSON Content-Type set
		// by WP_REST_Server doesn't survive.
		while ( ob_get_level() > 0 ) {
			ob_end_clean();
		}

		$disp     = ( 'attachment' === strtolower( $disposition ) ) ? 'attachment' : 'inline';
		$filename = sanitize_file_name( $invoice_number . '.pdf' );
		header( 'Content-Type: application/pdf' );
		header( 'Content-Disposition: ' . $disp . '; filename="' . $filename . '"' );
		header( 'X-Content-Type-Options: nosniff' );
		header( 'Cache-Control: private, no-store, max-age=0' );
		if ( $send_length && $size > 0 ) {
			header( 'Content-Length: ' . $size );
		}
	}

	/**
	 * Belt-and-braces: deny direct HTTP access via .htaccess + index.php even
	 * though wif-private/ sits outside uploads/ and has no default URL.
	 */
	private static function protect_cache_dir( string $cache_dir ): void {
		$base = self::private_base_dir();
		if ( ! is_dir( $base ) ) {
			wp_mkdir_p( $base );
		}

		global $wp_filesystem;
		if ( empty( $wp_filesystem ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
			WP_Filesystem();
		}

		$htaccess = $base . '/.htaccess';
		if ( ! file_exists( $htaccess ) ) {
			$written = false;
			if ( $wp_filesystem ) {
				$written = $wp_filesystem->put_contents( $htaccess, "Require all denied\nDeny from all\n", FS_CHMOD_FILE );
			}
			if ( false === $written ) {
				// Fallback if WP_Filesystem fails.
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
				$written = @file_put_contents( $htaccess, "Require all denied\nDeny from all\n" ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			}
			if ( false === $written ) {
				// Loud failure: read-only FS or perms issue means PDFs aren't
				// HTTP-protected. Surface this so admins don't ship insecure.
				error_log( '[ZINV_PDF_Generator] Could not write .htaccess at ' . $htaccess . ' — PDF cache dir is not HTTP-protected.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			}
		}

		foreach ( [ $base, $cache_dir ] as $dir ) {
			$index = $dir . '/index.php';
			if ( ! file_exists( $index ) ) {
				$written = false;
				if ( $wp_filesystem ) {
					$written = $wp_filesystem->put_contents( $index, "<?php // Silence is golden.\n", FS_CHMOD_FILE );
				}
				if ( false === $written ) {
					// Fallback if WP_Filesystem fails.
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
					$written = @file_put_contents( $index, "<?php // Silence is golden.\n" ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				}
				if ( false === $written ) {
					error_log( '[ZINV_PDF_Generator] Could not write index.php silencer at ' . $index ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
				}
			}
		}
	}
}
