<?php
/**
 * Renders label sheets to PDF via Dompdf.
 *
 * @package PhoenixWP\PriceLabels
 */

declare(strict_types=1);

namespace PhoenixWP\PriceLabels\Pdf;

use Dompdf\Dompdf;
use Dompdf\Options;

defined( 'ABSPATH' ) || exit;

/**
 * Builds and streams Avery PDF label sheets.
 */
final class Pdf_Renderer {

	/** Hard cap for title wrap width (compact Avery labels bleed into neighbours if exceeded). */
	private const TITLE_CHARS_PER_LINE_MAX = 40;

	/** DejaVu Sans bold average glyph width (em) — conservative for Dompdf nowrap rows. */
	private const TITLE_CHAR_WIDTH_EM_BOLD = 0.57;

	/** Extra character margin so PHP wrap stays inside the visible label width. */
	private const TITLE_CHARS_PER_LINE_SAFETY = 1;

	private Sheet_Layout $layout;

	/**
	 * Typography + width context for the current sheet render pass.
	 *
	 * @var array<string, mixed>|null
	 */
	private ?array $render_context = null;

	/**
	 * Optional field visibility for the current render pass.
	 *
	 * @var array<string, bool>
	 */
	private array $visible_fields = array();

	/** Resolved style colors for the current render pass.
	 *
	 * @var array<string, string>
	 */
	private array $style_colors = array();

	public function __construct( ?Sheet_Layout $layout = null ) {
		$this->layout = $layout ?? new Sheet_Layout();
	}

	/**
	 * @param array<int, array<string, mixed>> $labels  Label data rows.
	 * @param array<string, mixed>             $options Optional render options.
	 */
	public function stream( array $labels, string $filename = 'price-labels.pdf', array $options = array() ): void {
		if ( ! class_exists( Dompdf::class ) || ! class_exists( \Dompdf\Cpdf::class ) ) {
			wp_die(
				esc_html__( 'PDF library incomplete. Re-run scripts/install-deps.ps1 or deploy a build ZIP with vendor/.', 'phoenix-price-labels-for-woocommerce' ),
				esc_html__( 'PDF Error', 'phoenix-price-labels-for-woocommerce' ),
				array( 'response' => 500 )
			);
		}

		$this->prepare_render_pass( $options );
		$html = $this->build_html( $labels );
		$this->reset_render_pass();

		$options = new Options();
		$options->set( 'isRemoteEnabled', false );
		$options->set( 'defaultFont', 'DejaVu Sans' );

		$dompdf = new Dompdf( $options );
		$dompdf->loadHtml( $html );
		$dompdf->setPaper( 'A4', 'portrait' );
		$dompdf->render();

		$dompdf->stream( sanitize_file_name( $filename ), array( 'Attachment' => true ) );
		exit;
	}

	/**
	 * @param array<int, array<string, mixed>> $labels Label data rows.
	 */
	private function build_html( array $labels ): string {
		$dims     = $this->layout->get_dimensions();
		$per_page = max( 1, $this->layout->labels_per_sheet() );
		$pages    = array_chunk( $labels, $per_page );

		$this->render_context = $this->build_render_context( $dims );
		$css                  = $this->build_css( $dims, $this->render_context );
		$body                 = '';

		foreach ( $pages as $page_labels ) {
			$body .= '<div class="sheet">';
			$body .= $this->render_sheet_labels( $page_labels, $dims );
			$body .= '</div>';
		}

		$this->render_context = null;

		// Dompdf requires a self-contained HTML document; not a WordPress enqueue context.
		return '<!DOCTYPE html><html><head><meta charset="utf-8"><style>' . $css . '</style></head><body>' . $body . '</body></html>'; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet
	}

	/**
	 * @param array<string, mixed> $options Render options from admin handler.
	 */
	private function prepare_render_pass( array $options ): void {
		$fields = isset( $options['visible_fields'] ) && is_array( $options['visible_fields'] )
			? $options['visible_fields']
			: array();

		if ( array() === $fields && class_exists( \PhoenixWP\PriceLabels\Labels\Label_Field_Settings::class ) ) {
			$fields = \PhoenixWP\PriceLabels\Labels\Label_Field_Settings::get_defaults();
		}

		$this->visible_fields = $fields;

		$style = isset( $options['style'] ) && is_array( $options['style'] )
			? $options['style']
			: array();

		$this->style_colors = $style;
	}

	private function reset_render_pass(): void {
		$this->visible_fields = array();
		$this->style_colors   = array();
		$this->render_context = null;
	}

	private function is_field_visible( string $field ): bool {
		return ! empty( $this->visible_fields[ $field ] );
	}

	/**
	 * @param array<string, float|int> $dims Layout dimensions.
	 * @param array<string, mixed>     $type Resolved typography.
	 */
	private function build_css( array $dims, array $type ): string {
		$lw            = $dims['label_width'];
		$lh            = $dims['label_height'];
		$pw            = $dims['page_width'];
		$ph            = $dims['page_height'];
		$name_size     = (string) $type['name_size'];
		$name_height   = (string) $type['name_height'];
		$name_line_h   = (string) $type['name_line_height'];
		$name_lines    = (int) $type['name_lines'];
		$price_size    = (string) $type['price_size'];
		$meta_size     = (string) $type['meta_size'];
		$unit_size     = (string) $type['unit_size'];
		$var_size      = (string) $type['var_size'];
		$pad           = (string) $type['pad'];
		$dual_title    = $this->context_uses_dual_title( $type );
		$bg            = esc_attr( (string) ( $this->style_colors['background'] ?? '#ffffff' ) );
		$text_primary  = esc_attr( (string) ( $this->style_colors['text_primary'] ?? '#111111' ) );
		$text_secondary = esc_attr( (string) ( $this->style_colors['text_secondary'] ?? '#333333' ) );
		$text_muted    = esc_attr( (string) ( $this->style_colors['text_muted'] ?? '#666666' ) );
		$sale_accent   = esc_attr( (string) ( $this->style_colors['sale_accent'] ?? '#111111' ) );
		$barcode_w     = 0.0;
		$barcode_h     = 0.0;
		$dual_title_css = '';

		if ( $dual_title ) {
			$dual_title_css = "
			.label--dual-title .title-block { display: block; margin: 0; padding: 0; }
			.label--dual-title p.title-line { display: block; margin: 0; padding: 0; font-weight: bold; overflow-x: hidden; white-space: nowrap; }
			.label--dual-title p.title-line--row2 { padding-bottom: 0.35mm; }
			.label--dual-title .title-placeholder { font-size: 1pt; }
			.label--dual-title .price { margin-top: 0.1mm; }
			.label--ultra-compact.label--dual-title p.title-line--row2 { padding-bottom: 0.15mm; }
			.label--ultra-compact .brand { margin-bottom: 0.1mm; }
			.label--ultra-compact .price { margin-top: 0.15mm; }
			.label--ultra-compact .unit-price { margin-top: 0.15mm; }
			.label--ultra-compact .meta { margin-top: 0.15mm; }";
		}

		return "
			@page { margin: 0; size: A4 portrait; }
			body { margin: 0; font-family: DejaVu Sans, sans-serif; color: {$text_primary}; }
			.sheet { position: relative; page-break-after: always; width: {$pw}mm; height: {$ph}mm; overflow: hidden; }
			.sheet:last-child { page-break-after: auto; }
			.label { position: absolute; overflow: hidden; box-sizing: border-box; padding: {$pad}; width: {$lw}mm; height: {$lh}mm; background: {$bg}; color: {$text_primary}; }
			.label__barcode-slot { text-align: right; margin-top: 0.3mm; line-height: 0; overflow: hidden; }
			.label .phoenix-price-labels-for-woocommerce-barcode-frame { width: {$barcode_w}mm; height: {$barcode_h}mm; min-width: {$barcode_w}mm; min-height: {$barcode_h}mm; max-width: {$barcode_w}mm; max-height: {$barcode_h}mm; border-collapse: collapse; table-layout: fixed; margin-left: auto; }
			.label .phoenix-price-labels-for-woocommerce-barcode-frame__cell { width: {$barcode_w}mm; height: {$barcode_h}mm; min-width: {$barcode_w}mm; min-height: {$barcode_h}mm; max-width: {$barcode_w}mm; max-height: {$barcode_h}mm; padding: 0; margin: 0; overflow: hidden; text-align: center; vertical-align: middle; line-height: 0; }
			.label .phoenix-price-labels-for-woocommerce-barcode__img { display: block; width: {$barcode_w}mm; height: {$barcode_h}mm; min-width: {$barcode_w}mm; min-height: {$barcode_h}mm; max-width: {$barcode_w}mm; max-height: {$barcode_h}mm; margin: 0 auto; padding: 0; border: 0; }
			.brand { font-size: {$meta_size}; font-weight: bold; color: {$text_secondary}; margin-bottom: 0.2mm; }
			.name, .title-line { color: {$text_primary}; }
			.name { font-size: {$name_size}; font-weight: bold; line-height: 1.15; max-height: {$name_height}; overflow: hidden; }
			{$dual_title_css}
			.variation { font-size: {$var_size}; color: {$text_secondary}; margin-top: 0.3mm; }
			.price { font-size: {$price_size}; font-weight: bold; margin-top: 0.4mm; color: {$text_primary}; }
			.regular { font-size: {$var_size}; text-decoration: line-through; color: {$text_muted}; margin-right: 1mm; }
			.sale { color: {$sale_accent}; font-weight: bold; }
			.unit-price { font-size: {$unit_size}; margin-top: 0.3mm; color: {$text_secondary}; }
			.meta { font-size: {$meta_size}; color: {$text_muted}; margin-top: 0.3mm; }
		";
	}

	/**
	 * @param array<string, float|int> $dims Layout dimensions.
	 * @return array<string, mixed>
	 */
	private function build_render_context( array $dims ): array {
		$compact = (float) $dims['label_height'] <= Sheet_Layout::COMPACT_LABEL_MAX_MM;
		$type    = $this->resolve_typography( $dims, $compact );

		$type['label_width']  = (float) $dims['label_width'];
		$type['label_height'] = (float) $dims['label_height'];

		return $type;
	}

	/**
	 * Whether labels reserve two title rows (compact retail presets).
	 *
	 * @param array<string, mixed>|null $context Render context.
	 */
	private function context_uses_dual_title( ?array $context ): bool {
		if ( null === $context ) {
			return false;
		}

		return (int) ( $context['name_lines'] ?? 1 ) >= 2;
	}

	/**
	 * @param array<string, mixed> $dims    Layout dimensions.
	 * @param bool                 $compact Whether label height is compact.
	 * @return array{name_size: string, name_height: string, name_line_height: string, name_lines: int, price_size: string, meta_size: string, unit_size: string, var_size: string, pad: string}
	 */
	private function resolve_typography( array $dims, bool $compact ): array {
		/** @var array<string, string|int> $custom */
		$custom = isset( $dims['typography'] ) && is_array( $dims['typography'] ) ? $dims['typography'] : array();

		$defaults = array(
			'name_size'        => $compact ? '7pt' : '8pt',
			'name_height'      => $compact ? '9mm' : '9mm',
			'name_line_height' => $compact ? '4.2mm' : '4.2mm',
			'name_lines'       => $compact ? 2 : 1,
			'price_size'       => $compact ? '10pt' : '11pt',
			'meta_size'        => $compact ? '5.5pt' : '6pt',
			'unit_size'        => $compact ? '6pt' : '6.5pt',
			'var_size'         => $compact ? '6.5pt' : '7pt',
			'pad'              => $compact ? '1mm 1.5mm' : '1.5mm 2mm',
		);

		$merged = array_merge( $defaults, $custom );

		return array(
			'name_size'        => (string) $merged['name_size'],
			'name_height'      => (string) $merged['name_height'],
			'name_line_height' => (string) $merged['name_line_height'],
			'name_lines'       => max( 1, (int) $merged['name_lines'] ),
			'price_size'       => (string) $merged['price_size'],
			'meta_size'        => (string) $merged['meta_size'],
			'unit_size'        => (string) $merged['unit_size'],
			'var_size'         => (string) $merged['var_size'],
			'pad'              => (string) $merged['pad'],
		);
	}

	/**
	 * @param array<int, array<string, mixed>> $page_labels Labels for one sheet.
	 * @param array<string, float|int>         $dims        Layout dimensions.
	 */
	private function render_sheet_labels( array $page_labels, array $dims ): string {
		$cols     = (int) $dims['columns'];
		$rows     = (int) $dims['rows'];
		$html     = '';
		$index    = 0;
		$capacity = $cols * $rows;

		for ( $row = 0; $row < $rows; $row++ ) {
			for ( $col = 0; $col < $cols; $col++ ) {
				if ( $index >= $capacity || ! isset( $page_labels[ $index ] ) ) {
					break 2;
				}

				$position = $this->get_label_position( $col, $row, $dims );
				$classes  = 'label';

				if ( $this->context_uses_dual_title( $this->render_context ) ) {
					$classes .= ' label--dual-title';
				}

				if ( $this->is_field_visible( 'barcode' ) && '' !== trim( (string) ( $page_labels[ $index ]['barcode_html'] ?? '' ) ) ) {
					$classes .= ' label--has-barcode';
				}

				if ( $this->uses_ultra_compact_label() ) {
					$classes .= ' label--ultra-compact';
				}

				$html .= '<div class="' . esc_attr( $classes ) . '" style="left:' . esc_attr( (string) $position['left'] ) . 'mm;top:' . esc_attr( (string) $position['top'] ) . 'mm;">';
				$html .= $this->render_label_cell( $page_labels[ $index ] );
				$html .= '</div>';
				++$index;
			}
		}

		return $html;
	}

	/**
	 * @param array<string, float|int> $dims Layout dimensions.
	 * @return array{left: float, top: float}
	 */
	private function get_label_position( int $column, int $row, array $dims ): array {
		$margin_left      = (float) $dims['margin_left'];
		$margin_top       = (float) $dims['margin_top'];
		$horizontal_pitch = (float) ( $dims['horizontal_pitch'] ?? $dims['label_width'] );
		$vertical_pitch   = (float) ( $dims['vertical_pitch'] ?? $dims['label_height'] );
		$column_offset    = 0.0;
		$row_offset       = 0.0;

		if ( isset( $dims['column_offsets'] ) && is_array( $dims['column_offsets'] ) ) {
			$column_offset = (float) ( $dims['column_offsets'][ $column ] ?? 0.0 );
		}

		if ( isset( $dims['row_offsets'] ) && is_array( $dims['row_offsets'] ) ) {
			$row_offset = (float) ( $dims['row_offsets'][ $row ] ?? 0.0 );
		}

		return array(
			'left' => $margin_left + ( $column * $horizontal_pitch ) + $column_offset,
			'top'  => $margin_top + ( $row * $vertical_pitch ) + $row_offset,
		);
	}

	/**
	 * Optional brand line shown above the product title.
	 *
	 * @param array<string, mixed> $label Label row.
	 */
	private function render_brand_prefix( array $label ): string {
		if ( ! $this->is_field_visible( 'brand' ) ) {
			return '';
		}

		$brand = trim( (string) ( $label['brand'] ?? '' ) );

		if ( '' === $brand ) {
			return '';
		}

		return '<div class="brand">' . esc_html( $brand ) . '</div>';
	}

	/**
	 * Dual-title layout with a fixed second row (text or invisible placeholder dot).
	 *
	 * @param array<string, mixed> $label Label row.
	 */
	private function render_dual_title_cell( array $label ): string {
		$ctx   = is_array( $this->render_context ) ? $this->render_context : array();
		$name  = (string) ( $label['name'] ?? '' );
		$lines = max( 2, (int) ( $ctx['name_lines'] ?? 2 ) );
		$split = $this->split_title_lines(
			$name,
			$lines,
			(float) ( $ctx['label_width'] ?? 60.0 ),
			(string) ( $ctx['name_size'] ?? '7.5pt' ),
			(string) ( $ctx['pad'] ?? '1mm 1.5mm' )
		);

		$name_size = (string) ( $ctx['name_size'] ?? '7.5pt' );
		$line_pt   = $this->resolve_title_line_height_pt( $name_size, (string) ( $ctx['name_line_height'] ?? '4.2mm' ) );
		$line_one  = esc_html( (string) ( $split[0] ?? '' ) );
		$line_two  = trim( (string) ( $split[1] ?? '' ) );

		if ( '' !== $line_two ) {
			$row_two = esc_html( $line_two );
		} else {
			$row_two = $this->render_title_placeholder_dot( (string) $line_pt );
		}

		$line_style = $this->build_title_line_style( $name_size, $line_pt );

		$html  = $this->render_brand_prefix( $label );
		$html .= '<div class="title-block">';
		$html .= '<p class="title-line" style="' . esc_attr( $line_style ) . '">' . $line_one . '</p>';
		if ( '' !== $line_two || ! $this->uses_ultra_compact_label() ) {
			$html .= '<p class="title-line title-line--row2" style="' . esc_attr( $line_style ) . '">' . $row_two . '</p>';
		}
		$html .= '</div>';

		$html .= $this->render_label_body(
			esc_html( (string) ( $label['variation_text'] ?? '' ) ),
			esc_html( (string) ( $label['price'] ?? '' ) ),
			esc_html( (string) ( $label['regular_price'] ?? '' ) ),
			! empty( $label['on_sale'] ),
			esc_html( (string) ( $label['unit_price'] ?? '' ) ),
			esc_html( (string) ( $label['sku'] ?? '' ) ),
			esc_html( (string) ( $label['ean'] ?? '' ) ),
			(string) ( $label['barcode_html'] ?? '' ),
			'0',
			true
		);

		return $html;
	}

	/**
	 * Invisible 1 pt placeholder so row 2 keeps its height in Dompdf.
	 */
	private function render_title_placeholder_dot( string $line_pt ): string {
		/**
		 * Filters the placeholder dot color on the second title row (match label paper).
		 *
		 * @param string $color Default white for standard insert sheets.
		 */
		$color = (string) apply_filters( 'phoenix_wp_price_labels_title_placeholder_color', '#ffffff' );

		return '<span class="title-placeholder" style="color:' . esc_attr( $color ) . ';font-size:1pt;line-height:' . esc_attr( $line_pt ) . 'pt;">.</span>';
	}

	private function render_label_body(
		string $variation,
		string $price,
		string $regular,
		bool $on_sale,
		string $unit,
		string $sku,
		string $ean,
		string $barcode,
		string $price_margin_pt = '0',
		bool $dual_title_price = false
	): string {
		$html = '';

		if ( $this->is_field_visible( 'variation' ) && '' !== $variation ) {
			$html .= '<div class="variation">' . $variation . '</div>';
		}

		if ( $dual_title_price ) {
			$price_style = ' style="margin-top:0.1mm;"';
		} elseif ( '0' !== $price_margin_pt ) {
			$price_style = ' style="padding-top:' . esc_attr( $price_margin_pt ) . 'pt;margin-top:0;"';
		} else {
			$price_style = ' style="margin-top:0;"';
		}

		$html .= '<div class="price"' . $price_style . '>';
		if ( $on_sale && '' !== $regular ) {
			$html .= '<span class="regular">' . $regular . '</span>';
		}
		$html .= '<span class="sale">' . $price . '</span></div>';

		if ( $this->is_field_visible( 'unit_price' ) && '' !== $unit ) {
			$html .= '<div class="unit-price">' . $unit . '</div>';
		}

		$meta = array();
		if ( $this->is_field_visible( 'sku' ) && '' !== $sku ) {
			$meta[] = 'SKU: ' . $sku;
		}
		if ( $this->is_field_visible( 'ean' ) && '' !== $ean ) {
			$meta[] = 'EAN: ' . $ean;
		}
		if ( $meta ) {
			$html .= '<div class="meta">' . esc_html( implode( ' · ', $meta ) ) . '</div>';
		}

		return $html;
	}

	/**
	 * Whether the active sheet uses 30 mm insert labels (Badgemaker 1451 / plain 30).
	 */
	private function uses_ultra_compact_label(): bool {
		$ctx = is_array( $this->render_context ) ? $this->render_context : array();

		return (float) ( $ctx['label_height'] ?? 99.0 ) <= 30.0;
	}

	/**
	 * @param array<string, mixed> $label Label row.
	 */
	private function render_label_cell( array $label ): string {
		return $this->context_uses_dual_title( $this->render_context )
			? $this->render_dual_title_cell( $label )
			: $this->render_standard_label_cell( $label );
	}

	/**
	 * @param array<string, mixed> $label Label row.
	 */
	private function render_standard_label_cell( array $label ): string {
		$name      = (string) ( $label['name'] ?? '' );
		$variation = esc_html( (string) ( $label['variation_text'] ?? '' ) );
		$price     = esc_html( (string) ( $label['price'] ?? '' ) );
		$regular   = esc_html( (string) ( $label['regular_price'] ?? '' ) );
		$on_sale   = ! empty( $label['on_sale'] );
		$unit      = esc_html( (string) ( $label['unit_price'] ?? '' ) );
		$sku       = esc_html( (string) ( $label['sku'] ?? '' ) );
		$ean       = esc_html( (string) ( $label['ean'] ?? '' ) );
		$barcode   = (string) ( $label['barcode_html'] ?? '' );

		$html = $this->render_brand_prefix( $label );
		$html .= '<div class="name">' . esc_html( $name ) . '</div>';
		$html .= $this->render_label_body( $variation, $price, $regular, $on_sale, $unit, $sku, $ean, $barcode );

		return $html;
	}

	/**
	 * Word-wraps a title into fixed lines for label layout.
	 *
	 * @return string[]
	 */
	private function split_title_lines( string $name, int $max_lines, float $label_width_mm, string $font_size, string $pad ): array {
		$max_lines = max( 1, $max_lines );
		$name      = trim( preg_replace( '/\s+/u', ' ', $name ) ?? $name );

		if ( '' === $name ) {
			return array_fill( 0, $max_lines, '' );
		}

		$chars_per_line = $this->estimate_chars_per_line( $label_width_mm, $font_size, $pad );
		$words          = preg_split( '/\s+/u', $name, -1, PREG_SPLIT_NO_EMPTY ) ?: array();
		$wrapped        = array();
		$current        = '';

		foreach ( $words as $word ) {
			while ( mb_strlen( $word ) > $chars_per_line ) {
				if ( '' !== $current ) {
					$wrapped[] = $current;
					$current   = '';
				}

				$wrapped[] = mb_substr( $word, 0, $chars_per_line );
				$word      = mb_substr( $word, $chars_per_line );
			}

			if ( '' === $word ) {
				continue;
			}

			$candidate = '' === $current ? $word : $current . ' ' . $word;

			if ( '' === $current || mb_strlen( $candidate ) <= $chars_per_line ) {
				$current = $candidate;
				continue;
			}

			$wrapped[] = $current;
			$current   = $word;
		}

		if ( '' !== $current ) {
			$wrapped[] = $current;
		}

		if ( count( $wrapped ) <= 1 ) {
			return array( $wrapped[0] ?? $name, '' );
		}

		if ( count( $wrapped ) > $max_lines ) {
			$tail    = implode( ' ', array_slice( $wrapped, $max_lines - 1 ) );
			$wrapped = array_slice( $wrapped, 0, $max_lines - 1 );
			$wrapped[] = $this->truncate_title_line( $tail, $chars_per_line );
		}

		while ( count( $wrapped ) < $max_lines ) {
			$wrapped[] = '';
		}

		return array_slice( $wrapped, 0, $max_lines );
	}

	private function estimate_chars_per_line( float $label_width_mm, string $font_size, string $pad ): int {
		$inner_width = max( 10.0, $label_width_mm - $this->parse_horizontal_padding_mm( $pad ) );
		$pt          = $this->parse_font_size_pt( $font_size );
		// DejaVu Sans bold runs wider than 0.50em in Dompdf; keep wrap inside nowrap cells.
		$char_width_mm = max( 1.0, $pt * 0.352778 * self::TITLE_CHAR_WIDTH_EM_BOLD );

		$chars = max( 8, (int) floor( $inner_width / $char_width_mm ) - self::TITLE_CHARS_PER_LINE_SAFETY );
		$chars = min( self::TITLE_CHARS_PER_LINE_MAX, $chars );

		/**
		 * Filters estimated characters per title line before word wrap.
		 *
		 * @param int    $chars       Character budget per line.
		 * @param float  $inner_width Usable width in mm.
		 * @param string $font_size   CSS font size for the title row.
		 */
		return (int) apply_filters( 'phoenix_wp_price_labels_title_chars_per_line', $chars, $inner_width, $font_size );
	}

	/**
	 * Shortens an overflow title row at a word boundary before appending an ellipsis.
	 */
	private function truncate_title_line( string $line, int $chars_per_line ): string {
		$line = trim( $line );

		if ( '' === $line || mb_strlen( $line ) <= $chars_per_line ) {
			return $line;
		}

		$ellipsis = '…';
		$limit    = max( 1, $chars_per_line - mb_strlen( $ellipsis ) );
		$cut      = mb_substr( $line, 0, $limit );
		$space    = mb_strrpos( $cut, ' ' );

		if ( false !== $space && $space >= (int) floor( $limit * 0.45 ) ) {
			$cut = mb_substr( $cut, 0, $space );
		}

		return rtrim( $cut ) . $ellipsis;
	}

	private function parse_font_size_pt( string $font_size ): float {
		if ( preg_match( '/([\d.]+)\s*pt/u', $font_size, $matches ) ) {
			return max( 1.0, (float) $matches[1] );
		}

		return 7.5;
	}

	private function parse_horizontal_padding_mm( string $pad ): float {
		$padding = $this->parse_padding_box( $pad );

		return $padding['left'] + $padding['right'];
	}

	/**
	 * @return array{top: float, right: float, bottom: float, left: float}
	 */
	private function parse_padding_box( string $pad ): array {
		$parts = preg_split( '/\s+/u', trim( $pad ) ) ?: array();
		$count = count( $parts );

		if ( 0 === $count ) {
			return array(
				'top'    => 1.0,
				'right'  => 1.5,
				'bottom' => 1.0,
				'left'   => 1.5,
			);
		}

		if ( 1 === $count ) {
			$value = $this->parse_css_mm( (string) $parts[0] );

			return array(
				'top'    => $value,
				'right'  => $value,
				'bottom' => $value,
				'left'   => $value,
			);
		}

		if ( 2 === $count ) {
			$vertical   = $this->parse_css_mm( (string) $parts[0] );
			$horizontal = $this->parse_css_mm( (string) $parts[1] );

			return array(
				'top'    => $vertical,
				'right'  => $horizontal,
				'bottom' => $vertical,
				'left'   => $horizontal,
			);
		}

		return array(
			'top'    => $this->parse_css_mm( (string) ( $parts[0] ?? '1mm' ) ),
			'right'  => $this->parse_css_mm( (string) ( $parts[1] ?? '1.5mm' ) ),
			'bottom' => $this->parse_css_mm( (string) ( $parts[2] ?? $parts[0] ?? '1mm' ) ),
			'left'   => $this->parse_css_mm( (string) ( $parts[3] ?? $parts[1] ?? '1.5mm' ) ),
		);
	}

	/**
	 * Ensures title row line-height fits bold DejaVu descenders (p, g, y) in Dompdf.
	 */
	private function resolve_title_line_height_pt( string $font_size, string $line_height ): float {
		$font_pt = $this->parse_css_pt( $font_size );
		$line_pt = (float) $this->mm_to_pt( $line_height );

		if ( $font_pt <= 0 ) {
			return $line_pt > 0 ? $line_pt : 12.0;
		}

		// Dompdf clips descenders when line-height is tight relative to font metrics.
		$minimum = round( $font_pt * 1.48, 2 );

		return max( $line_pt, $minimum );
	}

	/**
	 * Inline CSS for one dual-title row (no fixed height — avoids Dompdf glyph clipping).
	 */
	private function build_title_line_style( string $font_size, float $line_pt ): string {
		return 'margin:0;padding:0;font-size:' . esc_attr( $font_size )
			. ';font-weight:bold;line-height:' . esc_attr( (string) $line_pt )
			. 'pt;min-height:' . esc_attr( (string) $line_pt )
			. 'pt;overflow-x:hidden;white-space:nowrap;';
	}

	private function parse_css_pt( string $value ): float {
		if ( preg_match( '/([\d.]+)\s*pt/u', $value, $matches ) ) {
			return (float) $matches[1];
		}

		return 0.0;
	}

	private function parse_css_mm( string $value ): float {
		if ( preg_match( '/([\d.]+)\s*mm/u', $value, $matches ) ) {
			return (float) $matches[1];
		}

		return 0.0;
	}

	/**
	 * Converts mm CSS values to pt for Dompdf inline sizing.
	 */
	private function mm_to_pt( string $value ): string {
		$mm = $this->parse_css_mm( $value );

		if ( $mm <= 0 ) {
			return '9.6';
		}

		return number_format( $mm / 0.352778, 2, '.', '' );
	}
}
