<?php
/**
 * WC_Bookings_CSV_Importer class
 *
 * @package WooCommerce Bookings
 * @since   3.6.0
 */

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

if ( ! class_exists( 'WC_Bookings_Importer', false ) ) {
	include_once __DIR__ . '/class-wc-bookings-importer.php';
}

if ( ! class_exists( 'WC_Bookings_CSV_Importer_Controller', false ) ) {
	include_once __DIR__ . '/class-wc-bookings-csv-importer-controller.php';
}

/**
 * CSV importer for booking instances.
 *
 * Format mirrors WC_Bookings_CSV_Exporter output: same column ids, same JSON shape
 * for person counts. Datetime fields accept ISO 8601 with offset (preferred) or
 * a TZ-less wall clock interpreted as destination-site-local. Bare numeric epochs
 * are rejected. The mapping step lets users adapt arbitrary CSVs to those internal ids.
 *
 * @version 3.6.0
 */
class WC_Bookings_CSV_Importer extends WC_Bookings_Importer {

	/**
	 * Index of the row currently being parsed (used for set_parsed_data context).
	 *
	 * @var int
	 */
	protected $parsing_raw_data_index = 0;

	/**
	 * Per-parsed-row warnings, keyed by raw data row index.
	 *
	 * @var array<int, array<int, array>>
	 */
	private $row_warnings_by_index = array();

	/**
	 * Per-parsed-row failures, keyed by raw data row index.
	 *
	 * @var array<int, array>
	 */
	private $row_failures_by_index = array();

	/**
	 * Initialise importer.
	 *
	 * @param string $file   File path.
	 * @param array  $params Importer arguments.
	 */
	public function __construct( $file, $params = array() ) {
		$default_args = array(
			'start_pos'        => 0,
			'end_pos'          => -1,
			'lines'            => -1,
			'mapping'          => array(),
			'parse'            => false,
			'update_existing'  => false,
			'delimiter'        => ',',
			'prevent_timeouts' => true,
			'enclosure'        => '"',
			'escape'           => "\0",
		);

		$this->params = wp_parse_args( $params, $default_args );
		$this->file   = $file;

		if ( isset( $this->params['mapping']['from'], $this->params['mapping']['to'] ) ) {
			$this->params['mapping'] = array_combine( $this->params['mapping']['from'], $this->params['mapping']['to'] );
		}

		$this->read_file();
	}

	/**
	 * Read the CSV file into memory for the current batch.
	 */
	protected function read_file() {
		if ( WC_Bookings_CSV_Importer_Controller::is_within_phar( $this->file ) ) {
			wp_die( esc_html__( 'Invalid file path. Files must not be within a PHAR executable.', 'woocommerce-bookings' ) );
		}

		if ( ! WC_Bookings_CSV_Importer_Controller::is_within_abspath( $this->file ) && ! WC_Bookings_CSV_Importer_Controller::is_within_wp_content( $this->file ) ) {
			wp_die( esc_html__( 'Invalid file path. Files must be uploaded to a location inside the WordPress absolute path or wp-content.', 'woocommerce-bookings' ) );
		}

		if ( ! WC_Bookings_CSV_Importer_Controller::is_file_valid_csv( $this->file ) ) {
			wp_die( esc_html__( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce-bookings' ) );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Position-based streaming (fseek/ftell) is required for batched imports and is not supported by WP_Filesystem.
		$handle = fopen( $this->file, 'r' );

		if ( false !== $handle ) {
			$this->raw_keys = array_map( 'trim', fgetcsv( $handle, 0, $this->params['delimiter'], $this->params['enclosure'], $this->params['escape'] ) );

			if ( isset( $this->raw_keys[0] ) ) {
				$this->raw_keys[0] = $this->remove_utf8_bom( $this->raw_keys[0] );
			}

			if ( 0 !== $this->params['start_pos'] ) {
				fseek( $handle, (int) $this->params['start_pos'] );
			}

			while ( 1 ) {
				$row = fgetcsv( $handle, 0, $this->params['delimiter'], $this->params['enclosure'], $this->params['escape'] );

				if ( false !== $row ) {
					$this->raw_data[]                                 = $row;
					$this->file_positions[ count( $this->raw_data ) ] = ftell( $handle );

					if ( ( $this->params['end_pos'] > 0 && ftell( $handle ) >= $this->params['end_pos'] ) || 0 === --$this->params['lines'] ) {
						break;
					}
				} else {
					break;
				}
			}

			$this->file_position = ftell( $handle );

			fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the streamed handle opened above; WP_Filesystem has no equivalent.
		}

		if ( ! empty( $this->params['mapping'] ) ) {
			$this->set_mapped_keys();
		}

		if ( $this->params['parse'] ) {
			$this->set_parsed_data();
		}
	}

	/**
	 * Strip the UTF-8 BOM from the first header value if present.
	 *
	 * @param string $value String to handle.
	 * @return string
	 */
	protected function remove_utf8_bom( $value ) {
		if ( 'efbbbf' === substr( bin2hex( $value ), 0, 6 ) ) {
			$value = substr( $value, 3 );
		}

		return $value;
	}

	/**
	 * Translate raw CSV header values to internal column ids using the user's mapping.
	 */
	protected function set_mapped_keys() {
		$mapping = $this->params['mapping'];

		foreach ( $this->raw_keys as $key ) {
			$this->mapped_keys[] = isset( $mapping[ $key ] ) ? $mapping[ $key ] : $key;
		}
	}

	/**
	 * Per-column formatting callbacks. Mirrors WC_GC_CSV_Importer::get_formatting_callback().
	 *
	 * @return array
	 */
	protected function get_formatting_callback() {
		$data_formatting = array(
			'id'                       => array( $this, 'parse_int_field' ),
			'product_id'               => array( $this, 'parse_int_field' ),
			'resource_id'              => array( $this, 'parse_int_field' ),
			'order_id'                 => array( $this, 'parse_int_field' ),
			'order_item_id'            => array( $this, 'parse_int_field' ),
			'parent_id'                => array( $this, 'parse_int_field' ),
			'customer_id'              => array( $this, 'parse_int_field' ),
			'woo_customer_id'          => array( $this, 'parse_int_field' ),
			'cost'                     => array( $this, 'parse_decimal_field' ),
			'all_day'                  => array( $this, 'parse_bool_field' ),
			'start'                    => array( $this, 'parse_timestamp_field' ),
			'end'                      => array( $this, 'parse_timestamp_field' ),
			'date_created'             => array( $this, 'parse_timestamp_field' ),
			'date_cancelled'           => array( $this, 'parse_timestamp_field' ),
			'person_counts'            => array( $this, 'parse_person_counts_field' ),
			'note'                     => 'sanitize_textarea_field',
			'status'                   => array( $this, 'parse_status_field' ),
			'attendance_status'        => array( $this, 'parse_status_field' ),
			'local_timezone'           => array( $this, 'parse_timezone_field' ),
			'google_calendar_event_id' => 'wc_clean',
		);

		$regex_match_data_formatting = array(
			'/meta:*/' => 'wp_kses_post',
		);

		$callbacks = array();

		foreach ( $this->get_mapped_keys() as $heading ) {
			$callback = 'wc_clean';

			if ( isset( $data_formatting[ $heading ] ) ) {
				$callback = $data_formatting[ $heading ];
			} else {
				foreach ( $regex_match_data_formatting as $regex => $regex_callback ) {
					if ( preg_match( $regex, $heading ) ) {
						$callback = $regex_callback;
						break;
					}
				}
			}

			$callbacks[] = $callback;
		}

		/**
		 * Filters the per-column formatting callbacks used to parse CSV cells.
		 *
		 * @since 3.6.0
		 *
		 * @param callable[]               $callbacks Per-column formatter callbacks, indexed against the mapped headers.
		 * @param WC_Bookings_CSV_Importer $importer  Importer instance.
		 */
		return apply_filters( 'woocommerce_bookings_importer_formatting_callbacks', $callbacks, $this );
	}

	/**
	 * Whether $haystack starts with $needle.
	 *
	 * @param string $haystack Sentence.
	 * @param string $needle   Excerpt.
	 * @return bool
	 */
	protected function starts_with( $haystack, $needle ) {
		return substr( $haystack, 0, strlen( $needle ) ) === $needle;
	}

	/**
	 * Collapse special multi-column groupings (currently just `meta:*`) into nested arrays.
	 *
	 * @param array $data Row data.
	 * @return array
	 */
	protected function expand_data( $data ) {
		/**
		 * Filters a parsed row before its multi-column groupings (e.g. `meta:*`) are expanded.
		 *
		 * @since 3.6.0
		 *
		 * @param array $data Parsed row data, keyed by mapped column id.
		 */
		$data      = apply_filters( 'woocommerce_bookings_importer_pre_expand_data', $data );
		$meta_data = array();

		foreach ( $data as $key => $value ) {
			if ( $this->starts_with( $key, 'meta:' ) ) {
				$meta_data[ str_replace( 'meta:', '', $key ) ] = $value;
				unset( $data[ $key ] );
			}
		}

		if ( ! empty( $meta_data ) ) {
			$data['meta_data'] = $meta_data;
		}

		return $data;
	}

	/**
	 * Map and format the raw rows into known fields.
	 */
	protected function set_parsed_data() {
		$parse_functions = $this->get_formatting_callback();
		$mapped_keys     = $this->get_mapped_keys();
		$use_mb          = function_exists( 'mb_convert_encoding' );

		foreach ( $this->raw_data as $row_index => $row ) {
			if ( ! count( array_filter( $row ) ) ) {
				continue;
			}

			$this->parsing_raw_data_index = $row_index;

			$data = array();

			/**
			 * Fires before a single raw CSV row is converted to parsed data.
			 *
			 * @since 3.6.0
			 *
			 * @param array $row         Raw CSV row values.
			 * @param array $mapped_keys Mapped header keys for the current import.
			 */
			do_action( 'woocommerce_bookings_importer_before_set_parsed_data', $row, $mapped_keys );

			foreach ( $row as $id => $value ) {
				if ( empty( $mapped_keys[ $id ] ) ) {
					continue;
				}

				if ( $use_mb ) {
					$encoding = mb_detect_encoding( $value, mb_detect_order(), true );
					if ( $encoding ) {
						$value = mb_convert_encoding( $value, 'UTF-8', $encoding );
					} else {
						$value = mb_convert_encoding( $value, 'UTF-8', 'UTF-8' );
					}
				} else {
					$value = wp_check_invalid_utf8( $value, true );
				}

				$data[ $mapped_keys[ $id ] ] = call_user_func( $parse_functions[ $id ], $value );
			}

			$row = $this->expand_data( $data );

			if ( isset( $this->row_warnings_by_index[ $row_index ] ) ) {
				$row['__row_warnings'] = $this->row_warnings_by_index[ $row_index ];
			}
			if ( isset( $this->row_failures_by_index[ $row_index ] ) ) {
				$row['__row_failure'] = $this->row_failures_by_index[ $row_index ];
			}

			/**
			 * Filters a single parsed CSV row before it is appended to the parsed-data set.
			 *
			 * @since 3.6.0
			 *
			 * @param array                    $row      Parsed row data, keyed by internal column id.
			 * @param WC_Bookings_CSV_Importer $importer Importer instance.
			 */
			$this->parsed_data[] = apply_filters( 'woocommerce_bookings_importer_parsed_data_row', $row, $this );
		}
	}

	/**
	 * Build a short identifier for a row, used in error messages.
	 *
	 * @param array $parsed_data Parsed row.
	 * @return string
	 */
	protected function get_row_id( $parsed_data ) {
		$id         = isset( $parsed_data['id'] ) ? absint( $parsed_data['id'] ) : 0;
		$product_id = isset( $parsed_data['product_id'] ) ? absint( $parsed_data['product_id'] ) : 0;
		$row_data   = array();

		if ( $id ) {
			/* translators: %d: booking ID */
			$row_data[] = sprintf( _x( 'ID %d', 'csv-import', 'woocommerce-bookings' ), $id );
		}
		if ( $product_id ) {
			/* translators: %d: bookable product ID */
			$row_data[] = sprintf( _x( 'Product %d', 'csv-import', 'woocommerce-bookings' ), $product_id );
		}
		if ( isset( $parsed_data['start'] ) && $parsed_data['start'] ) {
			/* translators: %s: booking start datetime */
			$row_data[] = sprintf( _x( 'Start %s', 'csv-import', 'woocommerce-bookings' ), date_i18n( 'Y-m-d H:i', $parsed_data['start'] ) );
		}

		return implode( ', ', $row_data );
	}

	/**
	 * Iterate parsed rows and dispatch each to the persistence layer.
	 *
	 * Existing-ID rows are skipped unless the `update_existing` flag is set.
	 *
	 * @return array { imported[], failed[], updated[], skipped[] }
	 */
	public function import() {
		$this->start_time = time();
		$index            = 0;
		$update_existing  = ! empty( $this->params['update_existing'] );
		$data             = array(
			'imported' => array(),
			'failed'   => array(),
			'updated'  => array(),
			'skipped'  => array(),
			'warnings' => array(),
		);

		foreach ( $this->parsed_data as $parsed_data ) {
			/**
			 * Fires before a single parsed row is dispatched to the persistence layer.
			 *
			 * @since 3.6.0
			 *
			 * @param array $parsed_data Parsed row data for the booking about to be processed.
			 */
			do_action( 'woocommerce_bookings_import_before_import', $parsed_data );

			$id        = isset( $parsed_data['id'] ) ? absint( $parsed_data['id'] ) : 0;
			$id_exists = false;

			if ( $id ) {
				$id_exists = ( 'wc_booking' === get_post_type( $id ) );
			}

			if ( $id_exists && ! $update_existing ) {
				$data['skipped'][] = new WP_Error(
					'woocommerce_bookings_importer_error',
					__( 'A booking with this ID already exists.', 'woocommerce-bookings' ),
					array(
						'id'  => $id,
						'row' => $this->get_row_id( $parsed_data ),
					)
				);
				continue;
			}

			if ( $update_existing && $id && ! $id_exists ) {
				$data['skipped'][] = new WP_Error(
					'woocommerce_bookings_importer_error',
					__( 'No matching booking exists to update.', 'woocommerce-bookings' ),
					array(
						'id'  => $id,
						'row' => $this->get_row_id( $parsed_data ),
					)
				);
				continue;
			}

			$result = $this->process_item( $parsed_data );

			if ( is_wp_error( $result ) ) {
				$result->add_data( array( 'row' => $this->get_row_id( $parsed_data ) ) );
				if ( 'wc_bookings_importer_row_skipped' === $result->get_error_code() ) {
					$data['skipped'][] = $result;
				} else {
					$data['failed'][] = $result;
				}
			} else {
				if ( ! empty( $result['warnings'] ) ) {
					$data['warnings'] = array_merge( $data['warnings'], $result['warnings'] );
				}

				if ( $result['updated'] ) {
					$data['updated'][] = $result['id'];
				} else {
					$data['imported'][] = $result['id'];
				}
			}

			++$index;

			if ( $this->params['prevent_timeouts'] && ( $this->time_exceeded() || $this->memory_exceeded() ) ) {
				$this->file_position = $this->file_positions[ $index ];
				break;
			}
		}

		return $data;
	}

	/*
	|--------------------------------------------------------------------------
	| Field parsers.
	|--------------------------------------------------------------------------
	*/

	/**
	 * Parse a CSV datetime cell into a fake-UTC booking timestamp.
	 *
	 * Accepts ISO 8601 with offset (preferred) or a TZ-less wall-clock string in
	 * destination-site time. Numeric epochs and unparseable strings are rejected: 0 is
	 * returned and a warning is recorded on the row for the caller. An offset that does
	 * not match the site offset is NOT a rejection — a valid non-zero timestamp is
	 * returned with a warning attached. 0 is therefore returned only on empty or invalid
	 * input, never on the offset-mismatch path.
	 *
	 * @param string $value Field value.
	 * @return int Fake-UTC timestamp, or 0 only when the value is empty / invalid.
	 */
	public function parse_timestamp_field( $value ) {
		if ( '' === $value || null === $value ) {
			return 0;
		}

		$warning = null;
		$result  = wc_bookings_parse_csv_datetime( $value, $warning );

		if ( is_wp_error( $result ) ) {
			$this->stash_row_failure( $result->get_error_message(), $value );
			// Still record the warning so the user understands WHY the row failed.
			$this->record_row_warning( 'invalid_datetime', $result->get_error_message(), array( 'value' => $value ) );
			return 0;
		}

		if ( null !== $warning ) {
			$this->record_row_warning( $warning['code'], $this->format_offset_mismatch_message( $warning ), $warning );
		}

		return (int) $result;
	}

	/**
	 * Record a per-row warning during CSV parsing.
	 *
	 * Overrides the abstract base to attach warnings to the specific raw data row index
	 * being parsed, so warnings are correctly attributed when process_item walks rows
	 * one at a time.
	 *
	 * @since 3.6.0
	 *
	 * @param string $code    Warning code.
	 * @param string $message Human-readable message.
	 * @param array  $context Structured context.
	 */
	public function record_row_warning( $code, $message, $context = array() ) {
		$idx = $this->parsing_raw_data_index;
		if ( ! isset( $this->row_warnings_by_index[ $idx ] ) ) {
			$this->row_warnings_by_index[ $idx ] = array();
		}
		$this->row_warnings_by_index[ $idx ][] = array(
			'code'    => $code,
			'message' => $message,
			'context' => $context,
		);
	}

	/**
	 * Mark the row currently being parsed as failed.
	 *
	 * Picked up by process_item() through the reserved __row_failure key on the parsed row.
	 *
	 * @since 3.6.0
	 *
	 * @param string $message Human-readable failure reason.
	 * @param string $value   Original CSV value that triggered the failure.
	 */
	private function stash_row_failure( $message, $value ) {
		$this->row_failures_by_index[ $this->parsing_raw_data_index ] = array(
			'message' => $message,
			'value'   => $value,
		);
	}

	/**
	 * Format a human-readable message for an offset_mismatch warning.
	 *
	 * @param array $warning Warning context from wc_bookings_parse_csv_datetime.
	 * @return string
	 */
	protected function format_offset_mismatch_message( $warning ) {
		return sprintf(
			/* translators: 1: wall clock value kept from the CSV, 2: CSV row offset, 3: destination site offset. */
			__( 'Kept time as written: %1$s (site local). CSV offset %2$s does not match site offset %3$s — no timezone conversion was applied.', 'woocommerce-bookings' ),
			$warning['wall_clock'],
			$warning['csv_offset'],
			$warning['site_offset']
		);
	}

	/**
	 * Parse '1'/'0', 'yes'/'no', booleans → 'yes' or 'no'. Unknown values fall through to wc_clean().
	 *
	 * @param string $value Field value.
	 * @return string
	 */
	public function parse_bool_field( $value ) {
		if ( in_array( $value, array( '0', 'no', 'FALSE', 'false', 'False' ), true ) ) {
			return 'no';
		}

		if ( in_array( $value, array( '1', 'yes', 'TRUE', 'true', 'True' ), true ) ) {
			return 'yes';
		}

		return wc_clean( $value );
	}

	/**
	 * Parse an integer field, stripping the CSV-injection escape character if present.
	 *
	 * @param string $value Field value.
	 * @return int
	 */
	public function parse_int_field( $value ) {
		$value = $this->unescape_data( $value );

		return intval( $value );
	}

	/**
	 * Parse a decimal field using wc_format_decimal().
	 *
	 * @param string $value Field value.
	 * @return string
	 */
	public function parse_decimal_field( $value ) {
		if ( '' === $value ) {
			return $value;
		}

		$value = $this->unescape_data( $value );

		return wc_format_decimal( (float) $value );
	}

	/**
	 * Parse the person-counts JSON column produced by the exporter.
	 *
	 * @param string $value Field value.
	 * @return array
	 */
	public function parse_person_counts_field( $value ) {
		if ( empty( $value ) ) {
			return array();
		}

		$decoded = json_decode( $value, true );

		if ( is_array( $decoded ) ) {
			$counts = array();
			foreach ( $decoded as $person_type_id => $count ) {
				$counts[ absint( $person_type_id ) ] = absint( $count );
			}
			return $counts;
		}

		return array();
	}

	/**
	 * Parse a status key. Strips the `wc-` prefix WooCommerce stores internally.
	 *
	 * @param string $value Field value.
	 * @return string
	 */
	public function parse_status_field( $value ) {
		$value = wc_clean( $value );
		if ( strpos( $value, 'wc-' ) === 0 ) {
			$value = substr( $value, 3 );
		}

		return $value;
	}

	/**
	 * Parse a timezone string. Returns an empty string for invalid values rather than failing import.
	 *
	 * @param string $value Field value.
	 * @return string
	 */
	public function parse_timezone_field( $value ) {
		if ( empty( $value ) ) {
			return '';
		}

		$value = wc_clean( $value );
		try {
			new DateTimeZone( $value );
			return $value;
		} catch ( Exception $e ) {
			return '';
		}
	}
}
