<?php
/**
 * Deterministic Lite flow pagination.
 *
 * @package EntryFoundry
 */

namespace EntryFoundry\Core\Services\Hydrate;

// phpcs:disable Squiz.Commenting, Generic.Commenting.DocComment

final class FlowLayoutStep {
	/** @param array<string,mixed> $template @return array<string,mixed> */
	public function __invoke( array $template ): array {
		if ( empty( $template['page']['flow'] ) ) {
			$template['page_count'] = max( 1, (int) ( $template['page']['manual_page_count'] ?? 1 ) );
			return $template;
		}
		$page       = $template['page'];
		$margin     = isset( $page['margin_mm'] ) ? $page['margin_mm'] : array(
			'top'    => 10,
			'bottom' => 10,
		);
		$top        = (float) ( $margin['top'] ?? 10 );
		$bottom     = (float) $page['height_mm'] - (float) ( $margin['bottom'] ?? 10 );
		$page_gap   = 12.0;
		$cursor     = $top;
		$page_index = 0;
		$fixed      = array();
		$flowing    = array();
		foreach ( $template['elements'] as $element ) {
			$is_flowing_table = 'table' === $element['type'] && ! array_key_exists( 'flow', $element );
			if ( empty( $element['flow'] ) && ! $is_flowing_table ) {
				$global_y              = (float) $element['y_mm'];
				$computed_page         = max( 0, (int) floor( $global_y / ( (float) $page['height_mm'] + $page_gap ) ) );
				$element['page_index'] = max( $computed_page, (int) ( $element['page_index'] ?? 0 ) );
				$element['y_mm']       = $global_y - ( $element['page_index'] * ( (float) $page['height_mm'] + $page_gap ) );
				$fixed[]               = $element;
				$page_index            = max( $page_index, $element['page_index'] );
			} else {
				$flowing[] = $element;
			}
		}
		usort(
			$flowing,
			static function ( $a, $b ) {
				return (float) $a['y_mm'] <=> (float) $b['y_mm'];
			}
		);
		$output = $fixed;
		foreach ( $flowing as $element ) {
			$cursor = max( $cursor, (float) $element['y_mm'] );
			if ( 'table' === $element['type'] ) {
				$table_output = $this->paginate_table( $element, $cursor, $page_index, $top, $bottom );
				foreach ( $table_output['elements'] as $clone ) {
					$output[] = $clone;
				}
				$cursor     = $table_output['cursor'];
				$page_index = max( $page_index, $table_output['page_index'] );
				continue;
			}
			$content     = isset( $element['content'] ) ? (string) $element['content'] : '';
			$font        = (float) ( $element['style']['font_size_pt'] ?? 12 );
			$line_height = max( 3.5, $font * 0.352778 * (float) ( $element['style']['line_height'] ?? 1.25 ) );
			$chars       = max( 8, (int) floor( (float) $element['w_mm'] / max( 1, $font * 0.32 ) ) );
			$lines       = in_array( $element['type'], array( 'text', 'field' ), true ) ? explode( "\n", wordwrap( $content, $chars, "\n", true ) ) : array( '' );
			$chunk_index = 0;
			while ( $lines ) {
				if ( $cursor >= $bottom ) {
					++$page_index;
					$cursor = $top;
				}
				$capacity            = max( 1, (int) floor( ( $bottom - $cursor ) / $line_height ) );
				$chunk               = array_splice( $lines, 0, $capacity );
				$clone               = $element;
				$clone['page_index'] = $page_index;
				$clone['y_mm']       = $cursor;
				$clone['h_mm']       = max( $line_height, count( $chunk ) * $line_height );
				$clone['content']    = $this->unwrap_estimated_lines( $chunk );
				if ( $chunk_index > 0 ) {
					$clone['id'] .= '_page_' . ( $page_index + 1 );
				}
				$output[] = $clone;
				$cursor  += (float) $clone['h_mm'] + 2;
				++$chunk_index;
				if ( $lines ) {
					++$page_index;
					$cursor = $top;
				}
			}
		}
		$template['elements']   = $output;
		$template['page_count'] = max( $page_index + 1, (int) ( $template['page']['manual_page_count'] ?? 1 ) );
		return $template;
	}

	/** @param array<string,mixed> $element @return array{elements:array<int,array<string,mixed>>,cursor:float,page_index:int} */
	private function paginate_table( array $element, float $cursor, int $page_index, float $top, float $bottom ): array {
		$rows          = isset( $element['rows'] ) && is_array( $element['rows'] ) ? array_values( $element['rows'] ) : array();
		$table_style   = isset( $element['table_style'] ) && is_array( $element['table_style'] ) ? $element['table_style'] : array();
		$header_height = empty( $table_style['show_header'] ) && array_key_exists( 'show_header', $table_style ) ? 0.0 : (float) ( $table_style['header_height_mm'] ?? 9 );
		$row_height    = max( 4.0, (float) ( $table_style['row_height_mm'] ?? 10 ) );
		$output        = array();
		$chunk         = array();
		$chunk_heights = array();
		$chunk_start   = $cursor;
		$chunk_index   = 0;

		$flush = function () use ( &$output, &$chunk, &$chunk_heights, &$chunk_index, &$element, &$page_index, &$chunk_start, $header_height ) {
			if ( empty( $chunk ) ) {
				return;
			}
			$clone               = $element;
			$clone['rows']       = $chunk;
			$clone['page_index'] = $page_index;
			$clone['y_mm']       = $chunk_start;
			$clone['h_mm']       = $header_height + array_sum( $chunk_heights );
			if ( $chunk_index > 0 ) {
				$clone['id'] .= '_page_' . ( $page_index + 1 );
			}
			$output[]      = $clone;
			$chunk         = array();
			$chunk_heights = array();
			++$chunk_index;
		};

		foreach ( $rows as $row ) {
			$estimated_row_height = $this->estimate_table_row_height( $row, $element, $row_height );
			$next_height          = $header_height + array_sum( $chunk_heights ) + $estimated_row_height;
			if ( ! empty( $chunk ) && $cursor + $next_height > $bottom ) {
				$flush();
				++$page_index;
				$cursor      = $top;
				$chunk_start = $cursor;
			}
			if ( empty( $chunk ) && $cursor > $top && $cursor + $header_height + $estimated_row_height > $bottom ) {
				++$page_index;
				$cursor      = $top;
				$chunk_start = $cursor;
			}
			$chunk[]         = $row;
			$chunk_heights[] = $estimated_row_height;
		}

		$flush();
		$last   = end( $output );
		$cursor = ! empty( $last ) ? (float) $last['y_mm'] + (float) $last['h_mm'] + 2 : $cursor;
		return array(
			'elements'   => $output,
			'cursor'     => $cursor,
			'page_index' => $page_index,
		);
	}

	/**
	 * Estimate a rendered table row, including text that wraps inside cells.
	 *
	 * The renderer treats row_height_mm as a minimum. Keeping the same rule in
	 * the flow engine prevents trailers and following content from being placed
	 * inside rows that mPDF expands during layout.
	 *
	 * @param mixed               $row             Table row.
	 * @param array<string,mixed> $element         Table element.
	 * @param float               $minimum_height  Configured minimum row height.
	 * @return float
	 */
	private function estimate_table_row_height( $row, array $element, float $minimum_height ): float {
		if ( ! is_array( $row ) ) {
			return $minimum_height;
		}
		$cells = isset( $row['cells'] ) && is_array( $row['cells'] ) ? array_values( $row['cells'] ) : array();
		if ( ! $cells && isset( $row['value'] ) ) {
			$cells = array( $row['label'] ?? '', $row['value'] );
		}
		if ( ! $cells ) {
			return $minimum_height;
		}

		$columns       = isset( $element['columns'] ) && is_array( $element['columns'] ) ? array_values( $element['columns'] ) : array();
		$column_count  = max( 1, count( $cells ), count( $columns ) );
		$table_width   = max( 1.0, (float) ( $element['w_mm'] ?? 100 ) );
		$style         = isset( $element['style'] ) && is_array( $element['style'] ) ? $element['style'] : array();
		$font_size     = max( 6.0, (float) ( $style['font_size_pt'] ?? 10 ) );
		$line_height   = max( 3.5, $font_size * 0.352778 * (float) ( $style['line_height'] ?? 1.25 ) );
		$maximum_lines = 1;

		foreach ( $cells as $index => $cell ) {
			$text       = is_array( $cell ) ? (string) ( $cell['value'] ?? $cell['text'] ?? '' ) : (string) $cell;
			$width_pct  = isset( $columns[ $index ]['width_pct'] ) && is_numeric( $columns[ $index ]['width_pct'] ) ? (float) $columns[ $index ]['width_pct'] : 0.0;
			$cell_width = $table_width * ( $width_pct > 0 ? min( 100.0, $width_pct ) / 100 : 1 / $column_count );
			$cell_width = max( 4.0, $cell_width - 4.0 );
			$characters = max( 4, (int) floor( $cell_width / max( 0.8, $font_size * 0.352778 * 0.52 ) ) );
			$lines      = 0;
			foreach ( explode( "\n", $text ) as $paragraph ) {
				$wrapped = wordwrap( $paragraph, $characters, "\n", true );
				$lines  += max( 1, substr_count( $wrapped, "\n" ) + 1 );
			}
			$maximum_lines = max( $maximum_lines, $lines );
		}

		return max( $minimum_height, ( $maximum_lines * $line_height ) + 2.0 );
	}

	/** @param string[] $lines */
	private function unwrap_estimated_lines( array $lines ): string {
		$content = preg_replace( '/\s+([,.;:!?])/', '$1', implode( ' ', $lines ) );
		$content = preg_replace( '/\s{2,}/', ' ', (string) $content );
		return trim( (string) $content );
	}
}
