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

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

if ( ! class_exists( 'WC_Importer_Interface', false ) ) {
	include_once WC_ABSPATH . 'includes/interfaces/class-wc-importer-interface.php';
}

/**
 * Base class for booking CSV importers.
 *
 * Mirrors WC_GC_Importer: handles file/state tracking and delegates per-row processing
 * to subclasses via process_item(). Booking creation is implemented here so the only
 * thing CSV-format-specific lives in WC_Bookings_CSV_Importer.
 *
 * @version 3.9.0
 */
abstract class WC_Bookings_Importer implements WC_Importer_Interface {

	/**
	 * CSV file path.
	 *
	 * @var string
	 */
	protected $file = '';

	/**
	 * File pointer offset after the most recent batch.
	 *
	 * @var int
	 */
	protected $file_position = 0;

	/**
	 * Importer parameters.
	 *
	 * @var array
	 */
	protected $params = array();

	/**
	 * Raw CSV header row.
	 *
	 * @var array
	 */
	protected $raw_keys = array();

	/**
	 * Header row mapped to internal column ids.
	 *
	 * @var array
	 */
	protected $mapped_keys = array();

	/**
	 * Raw row data for the current batch.
	 *
	 * @var array
	 */
	protected $raw_data = array();

	/**
	 * Per-row file pointer offsets used to resume mid-batch on time/memory limits.
	 *
	 * @var array
	 */
	protected $file_positions = array();

	/**
	 * Parsed (formatted) row data for the current batch.
	 *
	 * @var array
	 */
	protected $parsed_data = array();

	/**
	 * Batch start time for the timeout guard.
	 *
	 * @var int
	 */
	protected $start_time = 0;

	/**
	 * Get the raw CSV header row.
	 *
	 * @return array
	 */
	public function get_raw_keys() {
		return $this->raw_keys;
	}

	/**
	 * Get the mapped header row, falling back to the raw headers if no mapping is set.
	 *
	 * @return array
	 */
	public function get_mapped_keys() {
		return ! empty( $this->mapped_keys ) ? $this->mapped_keys : $this->raw_keys;
	}

	/**
	 * Get the raw rows for the current batch.
	 *
	 * @return array
	 */
	public function get_raw_data() {
		return $this->raw_data;
	}

	/**
	 * Get the parsed rows for the current batch.
	 *
	 * @return array
	 */
	public function get_parsed_data() {
		/**
		 * Filters the parsed-row data exposed by the bookings CSV importer.
		 *
		 * @since 3.6.0
		 *
		 * @param array                $parsed_data Parsed rows for the current batch.
		 * @param WC_Bookings_Importer $importer    Importer instance.
		 */
		return apply_filters( 'woocommerce_bookings_importer_parsed_data', $this->parsed_data, $this );
	}

	/**
	 * Get importer parameters.
	 *
	 * @return array
	 */
	public function get_params() {
		return $this->params;
	}

	/**
	 * Get the file pointer position from the most recent read.
	 *
	 * @return int
	 */
	public function get_file_position() {
		return $this->file_position;
	}

	/**
	 * File-pointer position as a percentage of the total file size.
	 *
	 * @return int
	 */
	public function get_percent_complete() {
		$size = filesize( $this->file );
		if ( ! $size ) {
			return 0;
		}

		return absint( min( round( ( $this->file_position / $size ) * 100 ), 100 ) );
	}

	/**
	 * Process a single parsed row and persist the resulting WC_Booking.
	 *
	 * @param array $data Parsed row data, keyed by internal column id.
	 * @return array|WP_Error { id, updated } on success or a WP_Error on failure.
	 * @throws Exception When the row references an invalid product or fails to persist.
	 */
	protected function process_item( $data ) {
		try {
			/**
			 * Fires before a single parsed CSV row is processed into a booking.
			 *
			 * @since 3.6.0
			 *
			 * @param array $data Parsed row data, keyed by internal column id.
			 */
			do_action( 'woocommerce_bookings_import_before_process_item', $data );

			/**
			 * Filters a parsed CSV row before it is processed into a booking.
			 *
			 * @since 3.6.0
			 *
			 * @param array $data Parsed row data, keyed by internal column id.
			 */
			$data = apply_filters( 'woocommerce_bookings_import_process_item_data', $data );

			// Per-row failure marker from a field parser — bail out before touching the booking object.
			if ( ! empty( $data['__row_failure'] ) ) {
				$failure = $data['__row_failure'];
				return new WP_Error(
					'wc_bookings_importer_row_failure',
					$failure['message'],
					array(
						'value'  => isset( $failure['value'] ) ? $failure['value'] : '',
						'status' => 400,
					)
				);
			}

			$row_warnings = ! empty( $data['__row_warnings'] ) ? (array) $data['__row_warnings'] : array();
			unset( $data['__row_failure'], $data['__row_warnings'] );

			$existing_id = ! empty( $data['id'] ) ? absint( $data['id'] ) : 0;
			$updating    = false;
			$booking     = null;

			if ( $existing_id && $this->params['update_existing'] ) {
				try {
					$booking = new WC_Booking( $existing_id );
				} catch ( Exception $e ) {
					$booking = null;
				}

				if ( $booking && $booking->get_id() ) {
					$updating = true;
				} else {
					$booking = null;
				}
			}

			// Mandatory: a booking must always have a start and an end date.
			// For updates, this only applies when the columns were explicitly provided.
			$start_provided = array_key_exists( 'start', $data );
			$end_provided   = array_key_exists( 'end', $data );
			$start_missing  = $start_provided && empty( $data['start'] );
			$end_missing    = $end_provided && empty( $data['end'] );

			if ( ! $updating ) {
				if ( ! $start_provided || ! $end_provided || $start_missing || $end_missing ) {
					return new WP_Error(
						'wc_bookings_importer_row_skipped',
						__( 'Start and end dates are required to import a booking.', 'woocommerce-bookings' ),
						array( 'status' => 200 )
					);
				}
			} elseif ( $start_missing || $end_missing ) {
				return new WP_Error(
					'wc_bookings_importer_row_skipped',
					__( 'Cannot update a booking with empty start or end dates.', 'woocommerce-bookings' ),
					array( 'status' => 200 )
				);
			}

			// Validate that end is strictly after start when both are provided.
			if (
				array_key_exists( 'start', $data ) && array_key_exists( 'end', $data )
				&& ! empty( $data['start'] ) && ! empty( $data['end'] )
				&& $data['end'] <= $data['start']
			) {
				return new WP_Error(
					'wc_bookings_importer_row_skipped',
					__( 'End date must be later than start date.', 'woocommerce-bookings' ),
					array( 'status' => 200 )
				);
			}

			// New booking flow — must be tied to a bookable product.
			if ( ! $booking ) {
				if ( empty( $data['product_id'] ) ) {
					throw new Exception( __( 'A bookable product ID is required to import a booking.', 'woocommerce-bookings' ) );
				}

				// Any registered bookable type is valid here, not just the core 'booking' type:
				// satellite plugins (e.g. Accommodation Bookings) and the block product types
				// register themselves through `woocommerce_bookings_product_types`.
				$product = wc_get_product( absint( $data['product_id'] ) );
				if ( ! $product || ! is_wc_booking_product( $product ) ) {
					throw new Exception( __( 'Invalid bookable product ID.', 'woocommerce-bookings' ) );
				}

				$booking = new WC_Booking();
			}

			$status_to_set = isset( $data['status'] ) && '' !== $data['status'] ? $data['status'] : '';

			// Validate against the registered booking statuses. An unknown value from the
			// CSV would otherwise persist on the booking and confuse status transitions and
			// the admin UI, so fail the row with a clear message instead of setting it.
			if ( '' !== $status_to_set ) {
				$valid_statuses = array_unique(
					array_merge(
						array( 'was-in-cart' ),
						get_wc_booking_statuses( null ),
						get_wc_booking_statuses( 'user' ),
						get_wc_booking_statuses( 'cancel' )
					)
				);

				if ( ! in_array( $status_to_set, $valid_statuses, true ) ) {
					return new WP_Error(
						'wc_bookings_importer_row_failure',
						sprintf(
							/* translators: %s: the unrecognised status value from the CSV. */
							__( 'Unrecognised booking status "%s".', 'woocommerce-bookings' ),
							$status_to_set
						),
						array( 'status' => 400 )
					);
				}
			}

			// Past bookings whose original status is "confirmed" or "paid" are forced to "complete",
			// since the booking has already happened. Other statuses (cancelled, unpaid, etc.) keep
			// their CSV value to preserve the historical record.
			// Booking start/end props are fake-UTC integers (UTC representation == site-local wall clock),
			// so we deliberately compare against current_time('timestamp'), which uses the same convention.
			$auto_complete_statuses = array( 'confirmed', 'paid' );
			if (
				array_key_exists( 'end', $data )
				&& ! empty( $data['end'] )
				&& $data['end'] < current_time( 'timestamp' ) // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
				&& in_array( $status_to_set, $auto_complete_statuses, true )
			) {
				$original_status = $status_to_set;

				$row_warnings[] = array(
					'code'    => 'status_forced_complete',
					'message' => sprintf(
						/* translators: %s: the original status from the CSV. */
						__( 'Status was forced to "complete" because the end date is in the past (originally "%s").', 'woocommerce-bookings' ),
						$original_status
					),
					'context' => array( 'original_status' => $original_status ),
				);

				$status_to_set = 'complete';
			}

			// Apply settable fields. Leave 'id' and 'status' for separate handling below.
			$settable_fields = array(
				'product_id',
				'resource_id',
				'order_id',
				'order_item_id',
				'parent_id',
				'customer_id',
				'woo_customer_id',
				'cost',
				'all_day',
				'start',
				'end',
				'local_timezone',
				'note',
				'attendance_status',
				'google_calendar_event_id',
				'person_counts',
				'date_created',
				'date_cancelled',
			);

			foreach ( $settable_fields as $field ) {
				if ( ! array_key_exists( $field, $data ) ) {
					continue;
				}

				$value  = $data[ $field ];
				$setter = array( $booking, "set_{$field}" );

				if ( is_callable( $setter ) ) {
					call_user_func( $setter, $value );
				}
			}

			// Custom meta passthrough. Staged on the CRUD object before the save below so
			// it is persisted in the same write, and internal/protected keys with a setter
			// are handled by WC_Data::is_internal_meta_key() — mirroring WC core's product
			// CSV importer, which sets meta on the object and lets a single save persist it.
			if ( ! empty( $data['meta_data'] ) && is_array( $data['meta_data'] ) ) {
				foreach ( $data['meta_data'] as $meta_key => $meta_value ) {
					$booking->update_meta_data( $meta_key, $meta_value );
				}
			}

			/**
			 * Filters a booking object just before it is saved by the importer.
			 *
			 * @since 3.6.0
			 *
			 * @param WC_Booking $booking  Booking about to be saved.
			 * @param array      $data     Parsed row data.
			 * @param bool       $updating Whether this is an update of an existing booking.
			 */
			$booking = apply_filters( 'woocommerce_bookings_import_pre_save_booking_object', $booking, $data, $updating );

			if ( $updating ) {
				if ( '' !== $status_to_set ) {
					$booking->set_status( $status_to_set );
				}
				$booking->save();
				$booking_id = $booking->get_id();
			} else {
				$create_status = '' !== $status_to_set ? $status_to_set : 'unpaid';
				$booking->create( $create_status );
				$booking_id = $booking->get_id();
			}

			if ( ! $booking_id ) {
				throw new Exception( __( 'A database error occurred while importing this booking.', 'woocommerce-bookings' ) );
			}

			foreach ( $row_warnings as $idx => $row_warning ) {
				$row_warning['booking_id'] = $booking_id;
				$row_warnings[ $idx ]      = $row_warning;
			}

			/**
			 * Fires after a booking has been inserted/updated by the importer.
			 *
			 * @since 3.6.0
			 *
			 * @param WC_Booking $booking  Booking that was saved.
			 * @param array      $data     Parsed row data.
			 * @param bool       $updating Whether this was an update of an existing booking.
			 */
			do_action( 'woocommerce_bookings_import_inserted_booking_object', $booking, $data, $updating );

			return array(
				'id'       => $booking_id,
				'updated'  => $updating,
				'warnings' => $row_warnings,
			);
		} catch ( Exception $e ) {
			return new WP_Error( 'woocommerce_bookings_importer_error', $e->getMessage(), array( 'status' => $e->getCode() ) );
		}
	}

	/**
	 * Memory exceeded — abort batch when usage hits 90% of the limit.
	 *
	 * @return bool
	 */
	protected function memory_exceeded() {
		$memory_limit   = $this->get_memory_limit() * 0.9;
		$current_memory = memory_get_usage( true );
		$return         = $current_memory >= $memory_limit;

		/**
		 * Filters whether the importer should consider the memory limit exceeded.
		 *
		 * @since 3.6.0
		 *
		 * @param bool $return Whether memory usage has exceeded 90% of the PHP limit.
		 */
		return apply_filters( 'woocommerce_bookings_importer_memory_exceeded', $return );
	}

	/**
	 * PHP memory limit in bytes (defaults to 32GB if unlimited).
	 *
	 * @return int
	 */
	protected function get_memory_limit() {
		if ( function_exists( 'ini_get' ) ) {
			$memory_limit = ini_get( 'memory_limit' );
		} else {
			$memory_limit = '128M';
		}

		if ( ! $memory_limit || -1 === intval( $memory_limit ) ) {
			$memory_limit = '32000M';
		}

		return intval( $memory_limit ) * 1024 * 1024;
	}

	/**
	 * Time exceeded — abort batch after a sensible cutoff to survive shared-host timeouts.
	 *
	 * @return bool
	 */
	protected function time_exceeded() {
		/**
		 * Filters the default time limit (in seconds) for a single importer batch.
		 *
		 * @since 3.6.0
		 *
		 * @param int $seconds Maximum batch duration in seconds.
		 */
		$finish = $this->start_time + apply_filters( 'woocommerce_bookings_importer_default_time_limit', 20 );
		$return = time() >= $finish;

		/**
		 * Filters whether the importer should consider the time limit exceeded.
		 *
		 * @since 3.6.0
		 *
		 * @param bool $return Whether the current batch has run past its time budget.
		 */
		return apply_filters( 'woocommerce_bookings_importer_time_exceeded', $return );
	}

	/**
	 * Explode a comma-delimited cell, respecting escaped commas.
	 *
	 * @param string $value     Raw cell value.
	 * @param string $separator Delimiter (default: comma).
	 * @return array
	 */
	protected function explode_values( $value, $separator = ',' ) {
		$value  = str_replace( '\\,', '::separator::', $value );
		$values = explode( $separator, $value );
		$values = array_map( array( $this, 'explode_values_formatter' ), $values );

		return $values;
	}

	/**
	 * Trim and unescape a single value produced by explode_values().
	 *
	 * @param string $value Value to format.
	 * @return string
	 */
	protected function explode_values_formatter( $value ) {
		return trim( str_replace( '::separator::', ',', $value ) );
	}

	/**
	 * The exporter prepends a leading apostrophe to fields starting with =, +, - or @
	 * to neutralise CSV-injection attacks. Strip the marker on import.
	 *
	 * @param string $value Possibly-escaped value.
	 * @return string
	 */
	protected function unescape_data( $value ) {
		$active_content_triggers = array( "'=", "'+", "'-", "'@" );

		if ( in_array( mb_substr( $value, 0, 2 ), $active_content_triggers, true ) ) {
			$value = mb_substr( $value, 1 );
		}

		return $value;
	}

	/**
	 * Record a warning about a row during parsing.
	 *
	 * Subclasses must implement this to attach the warning to the specific row being
	 * parsed (see WC_Bookings_CSV_Importer::record_row_warning()), 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.
	 */
	abstract public function record_row_warning( $code, $message, $context = array() );
}
