<?php
/**
 * Export engine — orchestrates queries, formats preview data, and generates files.
 *
 * @package RawTypeOE
 */

namespace RawTypeOE\Free;

// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Orchestrates data queries and formats results for preview and export.
 */
class Export_Engine {

	/**
	 * Column registry service.
	 *
	 * @var Column_Registry
	 */
	private $column_registry;

	/**
	 * File manager service.
	 *
	 * @var File_Manager|null
	 */
	private $file_manager;

	/** Default chunk size for export processing. */
	const CHUNK_SIZE = 500;

	/** Default row limit for Free version. */
	const FREE_ROW_LIMIT = 500;

	/** Absolute maximum row limit to prevent memory exhaustion. */
	const MAX_ROW_LIMIT = 100000;

	/**
	 * Constructor.
	 *
	 * @param Column_Registry   $column_registry Column registry service.
	 * @param File_Manager|null $file_manager    File manager service.
	 */
	public function __construct( Column_Registry $column_registry, File_Manager $file_manager = null ) {
		$this->column_registry = $column_registry;
		$this->file_manager    = $file_manager;
	}

	/**
	 * Execute a full export to file.
	 *
	 * @param array $config {
	 *     Export configuration.
	 *     @type string $entity    Entity slug (orders, products, customers, cross-entity).
	 *     @type array  $filters   Filter definitions from query builder.
	 *     @type string $logic     Filter logic (and/or).
	 *     @type array  $columns   Column IDs to include.
	 *     @type string $format    Output format (csv, xlsx).
	 *     @type array  $options   Additional options (delimiter, bom, item_mode).
	 * }
	 * @return array|\WP_Error {
	 *     @type string $download_url Secure download URL.
	 *     @type string $file_name    Generated file name.
	 *     @type string $file_path    Absolute file path (internal use).
	 *     @type int    $count        Total rows exported.
	 *     @type int    $size         File size in bytes.
	 *     @type string $format       Export format used.
	 *     @type string $expires_at   ISO 8601 expiration timestamp.
	 * }
	 */
	public function export( $config ) {
		$prepared = $this->prepare_export_run( $config );
		if ( is_wp_error( $prepared ) ) {
			return $prepared;
		}

		/**
		 * Fires before an export is executed.
		 *
		 * @param array $config Export configuration.
		 * @param int   $total_count Total records to export.
		 */
		do_action( 'rawtype_oe_before_export', $config, $prepared['total_count'] );

		if ( function_exists( 'wp_raise_memory_limit' ) ) {
			wp_raise_memory_limit( 'admin' );
		}

		// Process in chunks.
		$chunk_size = self::CHUNK_SIZE;
		$page       = 1;
		$total_rows = 0;
		$row_limit  = self::get_row_limit();

		do {
			$this->extend_runtime_for_chunk();

			$items = $prepared['data_source']->fetch(
				$this->build_chunk_fetch_args( $prepared['query_args'], $prepared['column_ids'], $chunk_size, $page )
			);

			if ( empty( $items ) || ! is_array( $items ) ) {
				break;
			}

			$rows = $prepared['data_source']->normalize( $items, $prepared['column_ids'], $prepared['item_mode'] );

			// Apply row filter.
			foreach ( $rows as $index => $row ) {
				$row = $this->format_row_for_export( $prepared['entity'], $row );

				/**
				 * Filter export row data.
				 *
				 * @param array $row    Row data.
				 * @param array $config Export config.
				 */
				$rows[ $index ] = apply_filters( 'rawtype_oe_row_data', $row, $config );
			}

			// Truncate chunk if row limit would be exceeded.
			if ( $row_limit > 0 && ( $total_rows + count( $rows ) ) > $row_limit ) {
				$rows = array_slice( $rows, 0, $row_limit - $total_rows );
			}

			$this->write_export_rows( $prepared, $rows );
			$total_rows += count( $rows );

			// Stop if we've hit the row limit.
			if ( $row_limit > 0 && $total_rows >= $row_limit ) {
				break;
			}

			$has_more = count( $items ) === $chunk_size;
			++$page;
		} while ( $has_more );

		// Finish and get metadata.
		$result = $prepared['exporter']->finish();

		if ( $total_rows > 0 && empty( $result['size'] ) ) {
			return new \WP_Error(
				'export_file_error',
				__( 'The export file could not be written. Check that the uploads directory is writable and has free space.', 'rawtype-order-exporter-woocommerce' ),
				array( 'status' => 500 )
			);
		}

		$download_url = $this->file_manager->get_download_url( $prepared['file_path'], false, $prepared['download_name'] );
		$file_name    = $prepared['download_name'];
		$expires_at   = gmdate( 'c', time() + File_Manager::FILE_EXPIRY );

		$total_available = isset( $config['total_count'] ) ? absint( $config['total_count'] ) : $total_rows;
		$truncated       = $row_limit > 0 && $total_available > $row_limit;

		$export_result = array(
			'download_url'    => $download_url,
			'file_name'       => $file_name,
			'file_path'       => $prepared['file_path'],
			'count'           => $total_rows,
			'total_available' => $total_available,
			'truncated'       => $truncated,
			'row_limit'       => $row_limit,
			'size'            => $result['size'],
			'format'          => $prepared['format'],
			'expires_at'      => $expires_at,
		);

		/**
		 * Fires after an export is completed.
		 *
		 * @param array $export_result Export result data.
		 * @param array $config        Export configuration.
		 */
		do_action( 'rawtype_oe_after_export', $export_result, $config );

		return $export_result;
	}

	/**
	 * Resolve both exported-row and source-record counts for a config.
	 *
	 * @param array $config Export configuration.
	 * @return array|\WP_Error
	 */
	public function get_export_counts( $config ) {
		$context = $this->resolve_export_context( $config );
		if ( is_wp_error( $context ) ) {
			return $context;
		}

		$source_count = $this->get_source_count_for_context( $context );

		return array(
			'total_rows'   => $this->get_total_count_for_context( $context, $source_count ),
			'source_count' => $source_count,
		);
	}

	/**
	 * Get the current row limit for the active installation.
	 *
	 * Returning 0 or a negative number disables the limit.
	 *
	 * @return int
	 */
	public static function get_row_limit() {
		$limit = (int) apply_filters( 'rawtype_oe_row_limit', self::FREE_ROW_LIMIT );

		if ( $limit < 1 ) {
			return 0;
		}

		return min( $limit, self::MAX_ROW_LIMIT );
	}

	/**
	 * Normalize a filter logic string.
	 *
	 * @param mixed $logic Raw logic value.
	 * @return string
	 */
	public static function normalize_logic( $logic ) {
		$logic = sanitize_key( (string) $logic );

		return in_array( $logic, array( 'and', 'or' ), true ) ? $logic : 'and';
	}

	/**
	 * Normalize an export format key.
	 *
	 * @param mixed $format Raw format value.
	 * @return string
	 */
	public static function normalize_format( $format ) {
		$format = sanitize_key( (string) $format );

		return '' !== $format ? $format : 'csv';
	}

	/**
	 * Normalize a column identifier while preserving reversible custom-field keys.
	 *
	 * @param mixed $column_key Raw column identifier.
	 * @return string
	 */
	public static function normalize_column_key( $column_key ) {
		if ( ! is_scalar( $column_key ) ) {
			return '';
		}

		$column_key = trim( (string) $column_key );
		if ( '' === $column_key ) {
			return '';
		}

		if ( 0 === strpos( $column_key, Custom_Field_Scanner::COLUMN_KEY_PREFIX ) ) {
			$encoded = strtolower( substr( $column_key, strlen( Custom_Field_Scanner::COLUMN_KEY_PREFIX ) ) );

			if ( '' === $encoded || 1 === ( strlen( $encoded ) % 2 ) || ! ctype_xdigit( $encoded ) ) {
				return '';
			}

			return Custom_Field_Scanner::COLUMN_KEY_PREFIX . $encoded;
		}

		if ( 0 === strpos( $column_key, 'meta__' ) ) {
			$raw_meta_key = substr( $column_key, 6 );
			$raw_meta_key = preg_replace( '/[^A-Za-z0-9_:\\-.]/', '', $raw_meta_key );

			return '' !== $raw_meta_key ? 'meta__' . $raw_meta_key : '';
		}

		return sanitize_key( $column_key );
	}

	/**
	 * Normalize selected columns while preserving custom labels.
	 *
	 * @param mixed $columns Raw column payload.
	 * @return array
	 */
	public static function normalize_columns( $columns ) {
		if ( ! is_array( $columns ) ) {
			return array();
		}

		$normalized = array();

		foreach ( $columns as $column ) {
			if ( is_string( $column ) ) {
				$key = self::normalize_column_key( $column );
				if ( '' !== $key ) {
					$normalized[] = $key;
				}
				continue;
			}

			if ( ! is_array( $column ) || empty( $column['key'] ) ) {
				continue;
			}

			$key = self::normalize_column_key( $column['key'] );
			if ( '' === $key ) {
				continue;
			}

			$normalized_column = array( 'key' => $key );
			if ( isset( $column['label'] ) && '' !== trim( (string) $column['label'] ) ) {
				$normalized_column['label'] = sanitize_text_field( $column['label'] );
			}

			$normalized[] = $normalized_column;
		}

		return $normalized;
	}

	/**
	 * Normalize an export config into the shared engine contract.
	 *
	 * @param mixed $config Raw config payload.
	 * @return array
	 */
	public static function normalize_export_config( $config ) {
		if ( ! is_array( $config ) ) {
			$config = array();
		}

		$source = isset( $config['source'] ) ? sanitize_key( (string) $config['source'] ) : 'manual';
		if ( '' === $source ) {
			$source = 'manual';
		}

		$normalized = array(
			'entity'       => isset( $config['entity'] ) ? sanitize_key( $config['entity'] ) : '',
			'filters'      => isset( $config['filters'] ) && is_array( $config['filters'] ) ? $config['filters'] : array(),
			'logic'        => self::normalize_logic( isset( $config['logic'] ) ? $config['logic'] : 'and' ),
			'columns'      => self::normalize_columns( isset( $config['columns'] ) ? $config['columns'] : array() ),
			'format'       => self::normalize_format( isset( $config['format'] ) ? $config['format'] : 'csv' ),
			'options'      => isset( $config['options'] ) && is_array( $config['options'] ) ? $config['options'] : array(),
			'cross_entity' => isset( $config['cross_entity'] ) && is_array( $config['cross_entity'] ) ? $config['cross_entity'] : array(),
			'source'       => $source,
		);

		if ( isset( $config['calculated_fields'] ) && is_array( $config['calculated_fields'] ) ) {
			$normalized['calculated_fields'] = $config['calculated_fields'];
		}

		if ( isset( $config['filename'] ) && is_string( $config['filename'] ) ) {
			$normalized['filename'] = sanitize_file_name( $config['filename'] );
		}

		if ( array_key_exists( 'total_count', $config ) && is_numeric( $config['total_count'] ) ) {
			$normalized['total_count'] = max( 0, absint( $config['total_count'] ) );
		}

		if ( array_key_exists( 'source_count', $config ) && is_numeric( $config['source_count'] ) ) {
			$normalized['source_count'] = max( 0, absint( $config['source_count'] ) );
		}

		if ( isset( $config['profile_id'] ) && is_numeric( $config['profile_id'] ) ) {
			$normalized['profile_id'] = absint( $config['profile_id'] );
		}

		if ( isset( $config['export_id'] ) && is_numeric( $config['export_id'] ) ) {
			$normalized['export_id'] = absint( $config['export_id'] );
		}

		return $normalized;
	}

	/**
	 * Execute a preview query with formatted data.
	 *
	 * @param string $entity  Entity slug.
	 * @param array  $filters Filter definitions.
	 * @param string $logic   Filter logic (and/or).
	 * @param array  $columns Selected columns with keys and optional custom labels.
	 * @param int    $limit   Number of sample records.
	 * @param array  $options Extra options (cross-entity config, etc.).
	 * @param bool   $count_only Whether to return only totals.
	 * @return array With 'total' and 'data' keys.
	 */
	public function preview( $entity, $filters = array(), $logic = 'and', $columns = array(), $limit = 10, $options = array(), $count_only = false ) {
		$args            = Query_Builder::build_query_args( $entity, $filters, $logic );
		$preview_context = $this->build_preview_row_context( $entity, $filters, $logic, $columns, $options );
		$result          = array(
			'total'   => 0,
			'data'    => array(),
			'limited' => false,
		);

		switch ( $entity ) {
			case 'orders':
				$result = $this->preview_orders( $args, $columns, $limit, $preview_context );
				break;
			case 'products':
				$result = $this->preview_products( $args, $columns, $limit, $preview_context );
				break;
			case 'customers':
				$result = $this->preview_customers( $args, $columns, $limit, $preview_context );
				break;
			case 'cross-entity':
				$result = $this->preview_cross_entity( $filters, $logic, $columns, $limit, $options, $count_only, $preview_context );
				break;
		}

		return $result;
	}

	/**
	 * Get supported entities (filterable for Pro extension).
	 *
	 * @return array Associative array of entity_slug => data_source_class.
	 */
	public function get_supported_entities() {
		$entities = array(
			'orders'       => 'RawTypeOE\\Free\\Data_Source_Orders',
			'products'     => 'RawTypeOE\\Free\\Data_Source_Products',
			'customers'    => 'RawTypeOE\\Free\\Data_Source_Customers',
			'cross-entity' => 'RawTypeOE\\Free\\Cross_Entity_Query',
		);

		/**
		 * Filter supported export entities.
		 *
		 * @param array $entities Entity slug => class name map.
		 */
		return apply_filters( 'rawtype_oe_entities', $entities );
	}

	/**
	 * Get available export formats (filterable for Pro).
	 *
	 * @return array Associative array of format => config.
	 */
	public function get_export_formats() {
		$formats = array(
			'csv'  => array(
				'label'    => __( 'CSV', 'rawtype-order-exporter-woocommerce' ),
				'class'    => 'RawTypeOE\\Free\\CSV_Exporter',
				'ext'      => 'csv',
				'mimetype' => 'text/csv',
			),
			'xlsx' => array(
				'label'    => __( 'Excel (XLSX)', 'rawtype-order-exporter-woocommerce' ),
				'class'    => 'RawTypeOE\\Free\\XLSX_Multisheet_Exporter',
				'ext'      => 'xlsx',
				'mimetype' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
			),
		);

		/**
		 * Filter available export formats.
		 *
		 * @param array $formats Format definitions.
		 */
		return apply_filters( 'rawtype_oe_export_formats', $formats );
	}

	/**
	 * Prepare an export run: resolve context, counts, exporter and target file.
	 *
	 * @param array $config Export configuration.
	 * @return array|\WP_Error
	 */
	private function prepare_export_run( $config ) {
		$context = $this->resolve_export_context( $config );
		if ( is_wp_error( $context ) ) {
			return $context;
		}

		$entity       = $context['entity'];
		$columns      = $context['columns'];
		$format       = $context['format'];
		$options      = $context['options'];
		$item_mode    = isset( $options['item_mode'] ) ? sanitize_key( $options['item_mode'] ) : 'concatenated';
		$source_count = $this->get_source_count_for_context( $context );
		$total_count  = $this->get_total_count_for_context( $context, $source_count );
		$formats      = $this->get_export_formats();

		if ( ! isset( $formats[ $format ] ) ) {
			return new \WP_Error(
				'invalid_format',
				__( 'Unsupported export format.', 'rawtype-order-exporter-woocommerce' ),
				array( 'status' => 400 )
			);
		}

		if ( empty( $columns ) ) {
			return new \WP_Error(
				'no_columns',
				__( 'At least one column must be selected.', 'rawtype-order-exporter-woocommerce' ),
				array( 'status' => 400 )
			);
		}

		if ( null === $this->file_manager ) {
			return new \WP_Error(
				'no_file_manager',
				__( 'File manager not available.', 'rawtype-order-exporter-woocommerce' ),
				array( 'status' => 500 )
			);
		}

		$column_ids = $this->extract_column_ids( $columns );
		$headers    = $this->resolve_headers( $columns );
		$sheet_map  = array();

		$multisheet_mode = isset( $options['multisheet_mode'] ) ? sanitize_key( $options['multisheet_mode'] ) : '';
		if ( 'by_entity' === $multisheet_mode && 'cross-entity' !== $entity ) {
			$multisheet_mode = 'single';
		}

		$use_multisheet = 'xlsx' === $format && ! empty( $multisheet_mode ) && 'single' !== $multisheet_mode;
		if ( $use_multisheet ) {
			$exporter = new XLSX_Multisheet_Exporter();

			if ( 'by_entity' === $multisheet_mode && 'cross-entity' === $entity ) {
				$sheet_map = $this->build_cross_entity_sheet_map( $columns );
			}
		} else {
			$exporter = $this->get_exporter( $format );
		}

		if ( null === $exporter ) {
			return new \WP_Error(
				'exporter_unavailable',
				__( 'Export format handler not available.', 'rawtype-order-exporter-woocommerce' ),
				array( 'status' => 500 )
			);
		}

		if ( 'xlsx' === $format && ! XLSX_Multisheet_Exporter::is_supported() ) {
			return new \WP_Error(
				'exporter_unavailable',
				__( 'Excel export requires the PHP zip extension. Ask your host to enable it, or export as CSV.', 'rawtype-order-exporter-woocommerce' ),
				array( 'status' => 500 )
			);
		}

		$injected_group_field = false;
		if ( $use_multisheet && 'by_group' === $multisheet_mode ) {
			$group_field = isset( $options['group_field'] ) ? self::normalize_column_key( $options['group_field'] ) : '';
			if ( ! empty( $group_field ) && ! in_array( $group_field, $column_ids, true ) ) {
				$column_ids[]         = $group_field;
				$injected_group_field = $group_field;
			}
		}

		if ( $injected_group_field && $use_multisheet ) {
			$options['strip_group_field'] = $injected_group_field;
		}

		$file_path = $this->file_manager->generate_file_path(
			$entity,
			$formats[ $format ]['ext']
		);

		$download_name = $this->file_manager->generate_download_name(
			$entity,
			$formats[ $format ]['ext'],
			$this->get_preferred_filename( $config )
		);

		$started = $exporter->start( $file_path, $headers, $options );
		if ( false === $started ) {
			return new \WP_Error(
				'export_file_error',
				__( 'Unable to create the export file. Check that the uploads directory is writable.', 'rawtype-order-exporter-woocommerce' ),
				array( 'status' => 500 )
			);
		}

		return array_merge(
			$context,
			array(
				'total_count'     => $total_count,
				'source_count'    => $source_count,
				'item_mode'       => $item_mode,
				'column_ids'      => $column_ids,
				'headers'         => $headers,
				'use_multisheet'  => $use_multisheet,
				'multisheet_mode' => $multisheet_mode,
				'sheet_map'       => $sheet_map,
				'exporter'        => $exporter,
				'file_path'       => $file_path,
				'download_name'   => $download_name,
			)
		);
	}

	/**
	 * Build fetch arguments for a single export chunk.
	 *
	 * @param array $query_args Prepared query args.
	 * @param array $column_ids Selected column IDs.
	 * @param int   $chunk_size Chunk size.
	 * @param int   $page       Current page number.
	 * @return array
	 */
	private function build_chunk_fetch_args( $query_args, $column_ids, $chunk_size, $page ) {
		return array_merge(
			$query_args,
			array(
				'limit'   => max( 1, absint( $chunk_size ) ),
				'paged'   => max( 1, absint( $page ) ),
				'columns' => $column_ids,
				'return'  => 'objects',
			)
		);
	}

	/**
	 * Write a normalized row set through the prepared exporter.
	 *
	 * @param array $prepared Prepared export state.
	 * @param array $rows     Normalized rows.
	 */
	private function write_export_rows( $prepared, $rows ) {
		if (
			$prepared['use_multisheet']
			&& 'by_entity' === $prepared['multisheet_mode']
			&& 'cross-entity' === $prepared['entity']
			&& $prepared['exporter'] instanceof XLSX_Multisheet_Exporter
			&& ! empty( $prepared['sheet_map'] )
		) {
			$this->write_cross_entity_multisheet_chunk( $prepared['exporter'], $rows, $prepared['sheet_map'] );
			return;
		}

		$prepared['exporter']->write_chunk( $rows );
	}

	/**
	 * Extract an optional preferred file name from the export config.
	 *
	 * @param array $config Export configuration.
	 * @return string
	 */
	private function get_preferred_filename( $config ) {
		if ( ! is_array( $config ) || empty( $config['filename'] ) || ! is_string( $config['filename'] ) ) {
			return '';
		}

		return sanitize_file_name( $config['filename'] );
	}

	/**
	 * Normalize export config and resolve entity-specific query context.
	 *
	 * @param array $config Export configuration.
	 * @return array|\WP_Error
	 */
	private function resolve_export_context( $config ) {
		$config       = self::normalize_export_config( $config );
		$entity       = $config['entity'];
		$filters      = $config['filters'];
		$logic        = $config['logic'];
		$cross_entity = $config['cross_entity'];

		$supported_entities = $this->get_supported_entities();
		if ( ! isset( $supported_entities[ $entity ] ) ) {
			return new \WP_Error(
				'invalid_entity',
				__( 'Unsupported entity for export.', 'rawtype-order-exporter-woocommerce' ),
				array( 'status' => 400 )
			);
		}

		$data_source = $this->get_data_source( $entity );
		if ( null === $data_source ) {
			return new \WP_Error(
				'no_data_source',
				__( 'No data source available for this entity.', 'rawtype-order-exporter-woocommerce' ),
				array( 'status' => 400 )
			);
		}

		$query_args = Query_Builder::build_query_args( $entity, $filters, $logic );

		// Propagate include_guests for customer exports.
		$options = $config['options'];
		if ( 'customers' === $entity && ! empty( $options['include_guests'] ) ) {
			$query_args['include_guests'] = true;
		}

		if ( 'cross-entity' === $entity ) {
			$cross_query = new Cross_Entity_Query();
			$validation  = $cross_query->validate_combination(
				isset( $cross_entity['primary_entity'] ) ? $cross_entity['primary_entity'] : 'orders',
				isset( $cross_entity['related_entities'] ) ? $cross_entity['related_entities'] : array()
			);

			if ( is_wp_error( $validation ) ) {
				return $validation;
			}

			$query_args = array(
				'filters'      => $filters,
				'logic'        => $logic,
				'cross_entity' => $cross_entity,
				'columns'      => $config['columns'],
			);
		}

		return array(
			'entity'       => $entity,
			'columns'      => $config['columns'],
			'format'       => $config['format'],
			'options'      => $config['options'],
			'data_source'  => $data_source,
			'query_args'   => $query_args,
			'total_count'  => $this->get_config_total_count( $config ),
			'source_count' => $this->get_config_source_count( $config ),
		);
	}

	/**
	 * Return a cached total count from the export config when available.
	 *
	 * @param array $config Export configuration.
	 * @return int|null
	 */
	private function get_config_total_count( $config ) {
		if ( ! is_array( $config ) || ! array_key_exists( 'total_count', $config ) ) {
			return null;
		}

		if ( ! is_numeric( $config['total_count'] ) ) {
			return null;
		}

		return max( 0, absint( $config['total_count'] ) );
	}

	/**
	 * Return a cached source-record count from the export config when available.
	 *
	 * @param array $config Export configuration.
	 * @return int|null
	 */
	private function get_config_source_count( $config ) {
		if ( ! is_array( $config ) || ! array_key_exists( 'source_count', $config ) ) {
			return null;
		}

		if ( ! is_numeric( $config['source_count'] ) ) {
			return null;
		}

		return max( 0, absint( $config['source_count'] ) );
	}

	/**
	 * Resolve the total row count for a prepared export context.
	 *
	 * @param array    $context      Prepared export context.
	 * @param int|null $source_count Known source-record count, if any.
	 * @return int
	 */
	private function get_total_count_for_context( $context, $source_count = null ) {
		if ( isset( $context['total_count'] ) && null !== $context['total_count'] ) {
			return absint( $context['total_count'] );
		}

		if ( $this->should_count_expanded_output_rows( $context ) ) {
			$row_limit = self::get_row_limit();

			// With a row limit in place we only need to know whether the export
			// truncates — counting the full dataset would walk every record
			// twice (once to count, once to export) on stores with thousands
			// of orders. Without a limit the pre-count is informational only,
			// so the cheap source count is good enough; export() reports the
			// exact written total.
			if ( $row_limit > 0 ) {
				return $this->count_output_rows_for_context( $context, $row_limit );
			}
		}

		if ( null === $source_count ) {
			$source_count = $this->get_source_count_for_context( $context );
		}

		return absint( $source_count );
	}

	/**
	 * Resolve the total number of source records for a prepared export context.
	 *
	 * @param array $context Prepared export context.
	 * @return int
	 */
	private function get_source_count_for_context( $context ) {
		if ( isset( $context['source_count'] ) && null !== $context['source_count'] ) {
			return absint( $context['source_count'] );
		}

		return absint( $context['data_source']->count( $context['query_args'] ) );
	}

	/**
	 * Determine whether exported rows must be counted exactly instead of reusing
	 * the source-record total.
	 *
	 * @param array $context Prepared export context.
	 * @return bool
	 */
	private function should_count_expanded_output_rows( $context ) {
		$entity     = isset( $context['entity'] ) ? sanitize_key( $context['entity'] ) : '';
		$options    = isset( $context['options'] ) && is_array( $context['options'] ) ? $context['options'] : array();
		$item_mode  = isset( $options['item_mode'] ) ? sanitize_key( $options['item_mode'] ) : 'concatenated';
		$column_ids = $this->extract_column_ids( isset( $context['columns'] ) ? $context['columns'] : array() );

		if ( 'products' === $entity ) {
			return 'expanded' === $item_mode;
		}

		if ( 'orders' !== $entity || 'expanded' !== $item_mode ) {
			return false;
		}

		foreach ( $column_ids as $column_id ) {
			if ( in_array( $column_id, Data_Source_Orders::ITEM_DETAIL_COLUMNS, true ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Count output rows by replaying normalization in paged chunks.
	 *
	 * @param array $context    Prepared export context.
	 * @param int   $stop_after Stop counting once this many rows are exceeded
	 *                          (0 counts the full dataset).
	 * @return int
	 */
	private function count_output_rows_for_context( $context, $stop_after = 0 ) {
		$total_rows = 0;
		$page       = 1;
		$chunk_size = self::CHUNK_SIZE;
		$stop_after = max( 0, (int) $stop_after );
		$column_ids = $this->extract_column_ids( isset( $context['columns'] ) ? $context['columns'] : array() );
		$options    = isset( $context['options'] ) && is_array( $context['options'] ) ? $context['options'] : array();
		$item_mode  = isset( $options['item_mode'] ) ? sanitize_key( $options['item_mode'] ) : 'concatenated';

		do {
			$this->extend_runtime_for_chunk();

			$items = $context['data_source']->fetch(
				$this->build_chunk_fetch_args( $context['query_args'], $column_ids, $chunk_size, $page )
			);

			if ( empty( $items ) || ! is_array( $items ) ) {
				break;
			}

			$total_rows += count(
				$context['data_source']->normalize( $items, $column_ids, $item_mode )
			);

			if ( $stop_after > 0 && $total_rows > $stop_after ) {
				break;
			}

			$has_more = count( $items ) === $chunk_size;
			++$page;
		} while ( $has_more );

		return $total_rows;
	}

	/**
	 * Keep long-running chunked loops alive on large datasets.
	 *
	 * Extends the PHP time limit and drops the in-memory object cache between
	 * chunks so WooCommerce entity hydration does not accumulate unbounded
	 * memory across thousands of rows.
	 */
	private function extend_runtime_for_chunk() {
		if ( function_exists( 'wc_set_time_limit' ) ) {
			wc_set_time_limit( 60 );
		}

		if ( function_exists( 'wp_cache_flush_runtime' ) ) {
			wp_cache_flush_runtime();
		}
	}

	/**
	 * Instantiate a data source for the given entity.
	 *
	 * @param string $entity Entity slug.
	 * @return Data_Source_Orders|null
	 */
	private function get_data_source( $entity ) {
		$entities = $this->get_supported_entities();

		if ( ! isset( $entities[ $entity ] ) ) {
			return null;
		}

		$class = $entities[ $entity ];

		if ( ! class_exists( $class ) ) {
			return null;
		}

		return new $class();
	}

	/**
	 * Instantiate an exporter for the given format.
	 *
	 * @param string $format Format slug (csv, xlsx).
	 * @return CSV_Exporter|XLSX_Multisheet_Exporter|null
	 */
	private function get_exporter( $format ) {
		$formats = $this->get_export_formats();

		if ( ! isset( $formats[ $format ] ) ) {
			return null;
		}

		$class = $formats[ $format ]['class'];

		if ( ! class_exists( $class ) ) {
			return null;
		}

		return new $class();
	}

	/**
	 * Extract column IDs from column definitions.
	 *
	 * Handles both simple string arrays and object arrays with 'key'.
	 *
	 * @param array $columns Column definitions.
	 * @return array Column ID strings.
	 */
	private function extract_column_ids( $columns ) {
		$ids = array();

		foreach ( $columns as $col ) {
			if ( is_string( $col ) ) {
				$key = self::normalize_column_key( $col );
				if ( '' !== $key ) {
					$ids[] = $key;
				}
			} elseif ( is_array( $col ) && isset( $col['key'] ) ) {
				$key = self::normalize_column_key( $col['key'] );
				if ( '' !== $key ) {
					$ids[] = $key;
				}
			}
		}

		return $ids;
	}

	/**
	 * Resolve human-readable headers from column definitions.
	 *
	 * Uses custom labels if provided, otherwise falls back to column key.
	 *
	 * @param array $columns Column definitions.
	 * @return array Header strings.
	 */
	private function resolve_headers( $columns ) {
		$headers = array();

		foreach ( $columns as $col ) {
			if ( is_string( $col ) ) {
				$meta_key  = Custom_Field_Scanner::parse_meta_key_from_column( self::normalize_column_key( $col ) );
				$headers[] = '' !== $meta_key ? $meta_key : $col;
			} elseif ( is_array( $col ) ) {
				if ( isset( $col['label'] ) && ! empty( $col['label'] ) ) {
					$headers[] = sanitize_text_field( $col['label'] );
				} elseif ( isset( $col['key'] ) ) {
					$key       = self::normalize_column_key( $col['key'] );
					$meta_key  = Custom_Field_Scanner::parse_meta_key_from_column( $key );
					$headers[] = '' !== $meta_key ? sanitize_text_field( $meta_key ) : sanitize_text_field( $key );
				} else {
					$headers[] = '';
				}
			}
		}

		return $headers;
	}

	/**
	 * Preview orders with column filtering and formatting.
	 *
	 * @param array $args            Query args.
	 * @param array $columns         Selected columns.
	 * @param int   $limit           Number of records.
	 * @param array $preview_context Preview row-filter context.
	 * @return array
	 */
	private function preview_orders( $args, $columns, $limit, $preview_context = array() ) {
		$result = array(
			'total' => 0,
			'data'  => array(),
		);

		$data_source     = new Data_Source_Orders();
		$result['total'] = $data_source->count( $args );

		if ( 0 === $result['total'] ) {
			return $result;
		}

		$sample_args = array_merge(
			$args,
			array(
				'limit'  => $limit,
				'paged'  => 1,
				'return' => 'objects',
			)
		);
		$orders      = $data_source->fetch( $sample_args );
		$column_keys = $this->extract_column_ids( $columns );
		$item_mode   = isset( $preview_context['options']['item_mode'] ) ? $preview_context['options']['item_mode'] : 'concatenated';

		$rows = $data_source->normalize( $orders, $column_keys, $item_mode );

		foreach ( $rows as $row ) {
			$row              = $this->format_order_data( $row );
			$row              = apply_filters( 'rawtype_oe_row_data', $row, $preview_context, null );
			$result['data'][] = $row;
		}

		return $result;
	}

	/**
	 * Preview products with column filtering and formatting.
	 *
	 * @param array $args            Query args.
	 * @param array $columns         Selected columns.
	 * @param int   $limit           Number of records.
	 * @param array $preview_context Preview row-filter context.
	 * @return array
	 */
	private function preview_products( $args, $columns, $limit, $preview_context = array() ) {
		$result = array(
			'total' => 0,
			'data'  => array(),
		);

		$data_source     = new Data_Source_Products();
		$result['total'] = $data_source->count( $args );

		if ( 0 === $result['total'] ) {
			return $result;
		}

		$sample_args = array_merge(
			$args,
			array(
				'limit'   => $limit,
				'orderby' => 'date',
				'order'   => 'DESC',
			)
		);

		$products    = $data_source->fetch( $sample_args );
		$column_keys = $this->extract_column_ids( $columns );
		$item_mode   = isset( $preview_context['options']['item_mode'] ) ? $preview_context['options']['item_mode'] : 'flat';

		$rows = $data_source->normalize( $products, $column_keys, $item_mode );

		foreach ( $rows as $row ) {
			$row              = $this->format_product_data( $row );
			$row              = apply_filters( 'rawtype_oe_row_data', $row, $preview_context, null );
			$result['data'][] = $row;
		}

		return $result;
	}

	/**
	 * Preview customers with column filtering and formatting.
	 *
	 * @param array $args            Query args.
	 * @param array $columns         Selected columns.
	 * @param int   $limit           Number of records.
	 * @param array $preview_context Preview row-filter context.
	 * @return array
	 */
	private function preview_customers( $args, $columns, $limit, $preview_context = array() ) {
		$result = array(
			'total' => 0,
			'data'  => array(),
		);

		$data_source = new Data_Source_Customers();

		// Propagate include_guests from preview options.
		$include_guests = ! empty( $preview_context['options']['include_guests'] );
		if ( $include_guests ) {
			$args['include_guests'] = true;
		}

		$result['total'] = $data_source->count( $args );

		if ( 0 === $result['total'] ) {
			return $result;
		}

		$sample_args = array_merge(
			$args,
			array(
				'limit'  => $limit,
				'paged'  => 1,
				'return' => 'objects',
			)
		);

		$customers   = $data_source->fetch( $sample_args );
		$column_keys = $this->extract_column_ids( $columns );

		$rows = $data_source->normalize( $customers, $column_keys );

		foreach ( $rows as $row ) {
			$row              = $this->format_customer_data( $row );
			$row              = apply_filters( 'rawtype_oe_row_data', $row, $preview_context, null );
			$result['data'][] = $row;
		}

		return $result;
	}

	/**
	 * Build row-filter context for preview rendering.
	 *
	 * @param string $entity  Entity slug.
	 * @param array  $filters Applied filters.
	 * @param string $logic   Filter logic.
	 * @param array  $columns Selected columns.
	 * @param array  $options Preview options.
	 * @return array
	 */
	private function build_preview_row_context( $entity, $filters, $logic, $columns, $options ) {
		$context = array(
			'entity'       => sanitize_key( $entity ),
			'filters'      => is_array( $filters ) ? $filters : array(),
			'logic'        => in_array( $logic, array( 'and', 'or' ), true ) ? $logic : 'and',
			'columns'      => is_array( $columns ) ? $columns : array(),
			'format'       => 'preview',
			'options'      => array(),
			'cross_entity' => array(),
			'source'       => 'preview',
		);

		if ( is_array( $options ) ) {
			if ( isset( $options['cross_entity'] ) && is_array( $options['cross_entity'] ) ) {
				$context['cross_entity'] = $options['cross_entity'];
			} elseif ( isset( $options['primary_entity'] ) || isset( $options['related_entities'] ) ) {
				$context['cross_entity'] = $options;
			}

			if ( isset( $options['calculated_fields'] ) && is_array( $options['calculated_fields'] ) ) {
				$context['calculated_fields'] = $options['calculated_fields'];
			}

			if ( isset( $options['options'] ) && is_array( $options['options'] ) ) {
				$context['options'] = $options['options'];
			}
		}

		return $context;
	}

	/**
	 * Keep preview row ordering aligned with the selected columns.
	 *
	 * @param array $row         Row data.
	 * @param array $column_keys Selected column keys.
	 * @return array
	 */
	private function select_preview_columns( $row, $column_keys ) {
		if ( empty( $column_keys ) ) {
			return $row;
		}

		$selected = array();
		foreach ( $column_keys as $key ) {
			$selected[ $key ] = isset( $row[ $key ] ) ? $row[ $key ] : '';
		}

		return $selected;
	}

	/**
	 * Build a per-entity column/header map for cross-entity multisheet exports.
	 *
	 * @param array $columns Selected columns.
	 * @return array
	 */
	private function build_cross_entity_sheet_map( $columns ) {
		$sheet_map = array();

		foreach ( (array) $columns as $column ) {
			$key   = '';
			$label = '';

			if ( is_string( $column ) ) {
				$key   = self::normalize_column_key( $column );
				$label = $column;

				$meta_key = Custom_Field_Scanner::parse_meta_key_from_column( $key );
				if ( '' !== $meta_key ) {
					$label = $meta_key;
				}
			} elseif ( is_array( $column ) && isset( $column['key'] ) ) {
				$key   = self::normalize_column_key( $column['key'] );
				$label = isset( $column['label'] ) && '' !== $column['label']
					? sanitize_text_field( $column['label'] )
					: sanitize_text_field( $column['key'] );
			}

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

			$entity = $this->resolve_cross_entity_sheet_slug( $key );
			if ( ! isset( $sheet_map[ $entity ] ) ) {
				$sheet_map[ $entity ] = array(
					'columns' => array(),
					'headers' => array(),
					'label'   => $this->get_cross_entity_sheet_label( $entity ),
				);
			}

			$sheet_map[ $entity ]['columns'][] = $key;
			$sheet_map[ $entity ]['headers'][] = $label;
		}

		return $sheet_map;
	}

	/**
	 * Dispatch a normalized cross-entity chunk into entity-specific worksheets.
	 *
	 * @param XLSX_Multisheet_Exporter $exporter  XLSX multisheet exporter.
	 * @param array                    $rows      Normalized export rows.
	 * @param array                    $sheet_map Sheet definitions keyed by entity.
	 */
	private function write_cross_entity_multisheet_chunk( XLSX_Multisheet_Exporter $exporter, $rows, $sheet_map ) {
		foreach ( $sheet_map as $definition ) {
			$columns = isset( $definition['columns'] ) && is_array( $definition['columns'] ) ? $definition['columns'] : array();
			$headers = isset( $definition['headers'] ) && is_array( $definition['headers'] ) ? $definition['headers'] : array();
			$label   = isset( $definition['label'] ) ? (string) $definition['label'] : __( 'Data', 'rawtype-order-exporter-woocommerce' );

			if ( empty( $columns ) ) {
				continue;
			}

			$sheet_rows = array();
			foreach ( (array) $rows as $row ) {
				$sheet_row = array();
				foreach ( $columns as $column_key ) {
					$sheet_row[ $column_key ] = isset( $row[ $column_key ] ) ? $row[ $column_key ] : '';
				}
				$sheet_rows[] = $sheet_row;
			}

			if ( ! empty( $sheet_rows ) ) {
				$exporter->write_entity_chunk( $sheet_rows, $label, $headers );
			}
		}
	}

	/**
	 * Resolve the sheet group for a cross-entity column key.
	 *
	 * @param string $column_key Column key.
	 * @return string
	 */
	private function resolve_cross_entity_sheet_slug( $column_key ) {
		if ( 0 === strpos( $column_key, 'customer_' ) ) {
			return 'customers';
		}

		if ( 0 === strpos( $column_key, 'product_' ) ) {
			return 'products';
		}

		return 'orders';
	}

	/**
	 * Resolve a translated sheet label for a cross-entity entity slug.
	 *
	 * @param string $entity Entity slug.
	 * @return string
	 */
	private function get_cross_entity_sheet_label( $entity ) {
		switch ( $entity ) {
			case 'customers':
				return __( 'Customers', 'rawtype-order-exporter-woocommerce' );

			case 'products':
				return __( 'Products', 'rawtype-order-exporter-woocommerce' );

			case 'orders':
			default:
				return __( 'Orders', 'rawtype-order-exporter-woocommerce' );
		}
	}

	/**
	 * Preview cross-entity data with multi-table joins.
	 *
	 * @param array  $filters         Query builder filters.
	 * @param string $logic           Filter logic.
	 * @param array  $columns         Selected columns.
	 * @param int    $limit           Preview limit.
	 * @param array  $options         Cross-entity options.
	 * @param bool   $count_only      Return only totals.
	 * @param array  $preview_context Preview row-filter context.
	 * @return array|\WP_Error
	 */
	private function preview_cross_entity( $filters, $logic, $columns, $limit, $options = array(), $count_only = false, $preview_context = array() ) {
		$query       = new Cross_Entity_Query();
		$preview_max = absint( $limit );
		$config      = isset( $options['cross_entity'] ) && is_array( $options['cross_entity'] ) ? $options['cross_entity'] : $options;
		$column_keys = $this->extract_column_ids( $columns );

		if ( $preview_max < 1 ) {
			$preview_max = 10;
		}

		$result = $query->query( $columns, $filters, $logic, $config, $preview_max, 0 );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		$rows = isset( $result['rows'] ) && is_array( $result['rows'] ) ? $result['rows'] : array();
		if ( ! $count_only ) {
			foreach ( $rows as $index => $row ) {
				$rows[ $index ] = $this->format_cross_entity_data( $row );
				$rows[ $index ] = apply_filters( 'rawtype_oe_row_data', $rows[ $index ], $preview_context, null );
				$rows[ $index ] = $this->select_preview_columns( $rows[ $index ], $column_keys );
			}
		}

		return array(
			'total'   => isset( $result['total'] ) ? absint( $result['total'] ) : 0,
			'data'    => $count_only ? array() : $rows,
			'limited' => ! empty( $result['limited'] ),
			'limit'   => isset( $result['limit'] ) ? absint( $result['limit'] ) : $preview_max,
		);
	}

	/**
	 * Format cross-entity row data for preview display.
	 *
	 * @param array $row Raw cross-entity row.
	 * @return array Formatted row.
	 */
	private function format_cross_entity_data( $row ) {
		if ( isset( $row['order_status'] ) && '' !== $row['order_status'] ) {
			$status = $row['order_status'];
			if ( 0 === strpos( $status, 'wc-' ) ) {
				$status = substr( $status, 3 );
			}
			$row['order_status'] = wc_get_order_status_name( $status );
		}

		if ( isset( $row['order_total'] ) && '' !== $row['order_total'] ) {
			$row['order_total'] = wp_strip_all_tags( wc_price( $row['order_total'] ) );
		}

		if ( isset( $row['order_date'] ) && '' !== $row['order_date'] ) {
			$timestamp = strtotime( $row['order_date'] );
			if ( false !== $timestamp ) {
				$date_format       = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
				$row['order_date'] = date_i18n( $date_format, $timestamp );
			}
		}

		if ( isset( $row['customer_total_spent'] ) && '' !== $row['customer_total_spent'] ) {
			$row['customer_total_spent'] = wp_strip_all_tags( wc_price( $row['customer_total_spent'] ) );
		}

		if ( isset( $row['product_price'] ) && '' !== $row['product_price'] ) {
			$row['product_price'] = wp_strip_all_tags( wc_price( $row['product_price'] ) );
		}

		return $row;
	}

	/**
	 * Normalize a row for spreadsheet export — translates slug-style values to
	 * human-readable labels without touching numeric prices or ISO dates so that
	 * Excel/Sheets formulas continue to work on the output.
	 *
	 * @param string $entity Entity slug.
	 * @param array  $row    Raw row from the data source.
	 * @return array
	 */
	private function format_row_for_export( $entity, $row ) {
		if ( ! is_array( $row ) ) {
			return $row;
		}

		switch ( $entity ) {
			case 'orders':
				if ( isset( $row['status'] ) && '' !== $row['status'] ) {
					$row['status'] = wc_get_order_status_name( $row['status'] );
				}
				break;

			case 'products':
				if ( isset( $row['status'] ) && '' !== $row['status'] ) {
					$row['status'] = $this->translate_product_status( $row['status'] );
				}
				if ( isset( $row['stock_status'] ) && '' !== $row['stock_status'] ) {
					$row['stock_status'] = $this->translate_stock_status( $row['stock_status'] );
				}
				break;

			case 'cross-entity':
				if ( isset( $row['order_status'] ) && '' !== $row['order_status'] ) {
					$status = $row['order_status'];
					if ( 0 === strpos( $status, 'wc-' ) ) {
						$status = substr( $status, 3 );
					}
					$row['order_status'] = wc_get_order_status_name( $status );
				}
				break;
		}

		return $row;
	}

	/**
	 * Map a product post status slug to its translated label.
	 *
	 * @param string $status Raw status slug.
	 * @return string
	 */
	private function translate_product_status( $status ) {
		$labels = array(
			'publish' => __( 'Published', 'rawtype-order-exporter-woocommerce' ),
			'draft'   => __( 'Draft', 'rawtype-order-exporter-woocommerce' ),
			'pending' => __( 'Pending', 'rawtype-order-exporter-woocommerce' ),
			'private' => __( 'Private', 'rawtype-order-exporter-woocommerce' ),
		);

		return isset( $labels[ $status ] ) ? $labels[ $status ] : $status;
	}

	/**
	 * Map a WooCommerce stock status slug to its translated label.
	 *
	 * @param string $status Raw stock status slug.
	 * @return string
	 */
	private function translate_stock_status( $status ) {
		$labels = array(
			'instock'     => __( 'In stock', 'rawtype-order-exporter-woocommerce' ),
			'outofstock'  => __( 'Out of stock', 'rawtype-order-exporter-woocommerce' ),
			'onbackorder' => __( 'On backorder', 'rawtype-order-exporter-woocommerce' ),
		);

		return isset( $labels[ $status ] ) ? $labels[ $status ] : $status;
	}

	/**
	 * Format order data for display.
	 *
	 * @param array $row Raw order row.
	 * @return array Formatted row.
	 */
	private function format_order_data( $row ) {
		// Format all date columns.
		$date_fields = array( 'date_created', 'date_modified', 'date_paid', 'date_completed' );
		$date_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
		foreach ( $date_fields as $field ) {
			if ( isset( $row[ $field ] ) && '' !== $row[ $field ] ) {
				$timestamp = strtotime( $row[ $field ] );
				if ( false !== $timestamp ) {
					$row[ $field ] = date_i18n( $date_format, $timestamp );
				}
			}
		}

		// Format all price columns.
		$price_fields = array( 'total', 'subtotal', 'discount_total', 'tax_total', 'shipping_total', 'coupon_discount', 'fees_total', 'refund_total', 'item_subtotal', 'item_total', 'item_tax' );
		foreach ( $price_fields as $field ) {
			if ( isset( $row[ $field ] ) && '' !== $row[ $field ] ) {
				$row[ $field ] = wp_strip_all_tags( wc_price( $row[ $field ] ) );
			}
		}

		if ( isset( $row['status'] ) ) {
			$row['status'] = wc_get_order_status_name( $row['status'] );
		}

		return $row;
	}

	/**
	 * Format product data for display.
	 *
	 * @param array $row Raw product row.
	 * @return array Formatted row.
	 */
	private function format_product_data( $row ) {
		$price_fields = array( 'price', 'regular_price', 'sale_price' );
		foreach ( $price_fields as $field ) {
			if ( isset( $row[ $field ] ) && '' !== $row[ $field ] ) {
				$row[ $field ] = wp_strip_all_tags( wc_price( $row[ $field ] ) );
			}
		}

		if ( isset( $row['status'] ) ) {
			$row['status'] = $this->translate_product_status( $row['status'] );
		}

		if ( isset( $row['stock_status'] ) ) {
			$row['stock_status'] = $this->translate_stock_status( $row['stock_status'] );
		}

		return $row;
	}

	/**
	 * Format customer data for display.
	 *
	 * @param array $row Raw customer row.
	 * @return array Formatted row.
	 */
	private function format_customer_data( $row ) {
		if ( isset( $row['date_registered'] ) && ! empty( $row['date_registered'] ) ) {
			$timestamp = strtotime( $row['date_registered'] );
			if ( false !== $timestamp ) {
				$row['date_registered'] = date_i18n( get_option( 'date_format' ), $timestamp );
			}
		}

		if ( isset( $row['total_spent'] ) ) {
			$row['total_spent'] = wp_strip_all_tags( wc_price( $row['total_spent'] ) );
		}

		return $row;
	}
}
