<?php
/**
 * The CSV Import class.
 *
 * @since      1.0
 * @package    RankMathPro
 * @subpackage RankMathPro\Admin
 * @author     Rank Math <support@rankmath.com>
 */

namespace RankMathPro\Admin\CSV_Import_Export;

use RankMath\Helpers\Arr;
use RankMath\Helpers\DB as DB_Helper;

defined( 'ABSPATH' ) || exit;

/**
 * CSV Importer class.
 *
 * @codeCoverageIgnore
 */
class Importer {

	/**
	 * Term slug => ID cache.
	 *
	 * @var array
	 */
	private static $term_ids = [];

	/**
	 * Settings array. Default values.
	 *
	 * @var array
	 */
	private $settings = [
		'not_applicable_value' => 'n/a',
		'clear_command'        => 'DELETE',
		'no_overwrite'         => true,
	];

	/**
	 * Lines in the CSV that could not be imported for any reason.
	 *
	 * @var array
	 */
	private $failed_rows = [];

	/**
	 * Lines in the CSV that could be imported successfully.
	 *
	 * @var array
	 */
	private $imported_rows = [];

	/**
	 * Error messages.
	 *
	 * @var array
	 */
	private $errors = [];

	/**
	 * SplFileObject instance.
	 *
	 * @var \SplFileObject
	 */
	private $spl;

	/**
	 * Column headers.
	 *
	 * @var array
	 */
	private $column_headers = [];

	/**
	 * Constructor.
	 *
	 * @return void
	 */
	public function __construct() {
		$this->load_settings();
	}

	/**
	 * Load settings.
	 *
	 * @return void
	 */
	public function load_settings() {
		$this->settings = apply_filters( 'rank_math/admin/csv_import_settings', wp_parse_args( get_option( 'rank_math_csv_import_settings', [] ), $this->settings ) );
	}

	/**
	 * Start import from file.
	 *
	 * @param string $file     Path to temporary CSV file.
	 * @param array  $settings Import settings.
	 * @return void
	 */
	public function start( $file, $settings = [] ) {
		$line_indices = $this->get_line_indices( $file );
		if ( empty( $line_indices ) ) {
			$status                = (array) get_option( 'rank_math_csv_import_status', [] );
			$status['errors']      = [ esc_html__( 'The CSV file is empty or invalid.', 'rank-math-pro' ) ];
			$status['failed_rows'] = [ 1 ];

			update_option( 'rank_math_csv_import_status', $status );
			return;
		}

		update_option( 'rank_math_csv_import', $file );
		update_option( 'rank_math_csv_import_settings', $settings );
		delete_option( 'rank_math_csv_import_status' );
		$this->load_settings();
		update_option( 'rank_math_csv_import_total', count( $line_indices ) );
		Import_Background_Process::get()->start( $line_indices );
	}

	/**
	 * Count non-empty lines in CSV file.
	 *
	 * @param string $file Path to CSV.
	 * @return int
	 */
	public function count_lines( $file ) {
		return count( $this->get_line_indices( $file ) );
	}

	/**
	 * Get line indices of non-empty data rows (excluding header).
	 *
	 * @param string $file Path to CSV.
	 * @return array Array of line indices that contain data.
	 */
	public function get_line_indices( $file ) {
		$spl         = new \SplFileObject( $file, 'r' );
		$indices     = [];
		$line_number = 0;

		while ( ! $spl->eof() ) {
			// Skip header (line 0) and empty lines.
			if ( $line_number > 0 && trim( $spl->fgets() ) !== '' ) {
				$indices[] = $line_number;
			}
			++$line_number;
		}

		return $indices;
	}

	/**
	 * Get specified line from CSV.
	 *
	 * @param string $file Path to file.
	 * @param int    $line Line number.
	 * @return string
	 */
	public function get_line( $file, $line ) {
		if ( empty( $this->spl ) ) {
			$this->spl = new \SplFileObject( $file );
		}

		$contents = null;

		if ( ! $this->spl->eof() ) {
			$this->spl->seek( $line );
			$contents = $this->spl->current();
		}

		return $contents;
	}

	/**
	 * Parse and return column headers (first line in CSV).
	 *
	 * @param string $file Path to file.
	 * @return array
	 */
	public function get_column_headers( $file ) {
		if ( ! empty( $this->column_headers ) ) {
			return $this->column_headers;
		}

		if ( empty( $this->spl ) ) {
			$this->spl = new \SplFileObject( $file );
		}

		if ( ! $this->spl->eof() ) {
			$this->spl->seek( 0 );
			$contents = $this->spl->current();
		}

		if ( empty( $contents ) ) {
			return [];
		}

		$this->column_headers = Arr::from_string( $contents, apply_filters( 'rank_math/csv_import/separator', ',' ) );
		return $this->column_headers;
	}

	/**
	 * Imports batch of rows started getting processed by WP_Background_Process.
	 *
	 * @param array $item Array of line numbers.
	 *
	 * @return void
	 */
	public function import_batch( $item ) {
		$data = [];
		foreach ( $item as $line_number ) {
			$row_data = $this->get_row_data( $line_number );
			if ( ! $row_data ) {
				continue;
			}
			$row_importer = new Import_Row( $row_data, $this->settings, false );
			$object_type  = $row_importer->object_type;
			foreach ( [ 'update', 'delete' ] as $action ) {
				if ( ! empty( $row_importer->meta_data[ $action ] ) ) {
					if ( empty( $data[ $object_type ][ $action ] ) ) {
						$data[ $object_type ][ $action ] = [];
					}
					$data[ $object_type ][ $action ] = array_merge( $data[ $object_type ][ $action ], $row_importer->meta_data[ $action ] );
				}
			}

			if ( $row_importer->processed ) {
				$this->row_imported( $line_number );
			}
		}

		foreach ( $data as $object_type => $object_data ) {
			if ( ! empty( $object_data['update'] ) ) {
				$this->update_object_metas( $object_data['update'], $object_type );
			}
			if ( ! empty( $object_data['delete'] ) ) {
				$this->delete_object_metas( $object_data['delete'], $object_type );
			}
		}
	}

	/**
	 * Get the table name to update for the current row being imported.
	 *
	 * @param string $object_type  Object type. Either of 'post', 'term' or 'user'.
	 *
	 * @return string
	 */
	private function get_table_name( $object_type ) {
		global $wpdb;
		$type = "{$object_type}meta";
		return $wpdb->$type;
	}

	/**
	 * Deletes object metas.
	 * Note: We would delete the entry from the meta table, when the value read from CSV is empty for each meta.
	 *
	 * @param array  $metas_to_delete  Array of metas to delete.
	 * @param string $object_type      Object type. Either of 'post', 'term' or 'user'.
	 *
	 * @return void
	 */
	public function delete_object_metas( $metas_to_delete, $object_type ) {
		global $wpdb;
		$where_conditions = [];
		$table_name       = $this->get_table_name( $object_type );
		$id_column_name   = "{$object_type}_id"; // Can be post_id, term_id or user_id.
		foreach ( $metas_to_delete as $meta_to_delete ) {
			$where_conditions[] = $wpdb->prepare(
				'(%i=%d AND meta_key=%s)',
				$id_column_name,
				$meta_to_delete[ $id_column_name ],
				$meta_to_delete['meta_key']
			);
		}
		$wpdb->query( "DELETE FROM {$table_name} WHERE " . implode( ' OR ', $where_conditions ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Updates the object metas tables.
	 *
	 * @param array  $meta_updates Array of object metas to update.
	 * @param string $object_type  Object type. Either of 'post', 'term' or 'user'.
	 *
	 * @return void
	 */
	public function update_object_metas( $meta_updates, $object_type ) {
		if ( empty( $meta_updates ) ) {
			return;
		}
		$this->update_existing_metas( $meta_updates, $object_type );
		$this->insert_new_metas( $meta_updates, $object_type );
	}

	/**
	 * Updates existing metas.
	 *
	 * @param array  $meta_updates Array of object metas to update.
	 * @param string $object_type  Object type. Either of 'post', 'term' or 'user'.
	 *
	 * @return void
	 */
	private function update_existing_metas( $meta_updates, $object_type ) {
		global $wpdb;
		$table_name = $this->get_table_name( $object_type );

		$id_column_name = "{$object_type}_id";
		$values_sql     = [];
		foreach ( $meta_updates as $i => $row ) {
			$post_id    = (int) $row[ $id_column_name ];
			$meta_key   = addslashes( $row['meta_key'] );
			$meta_value = addslashes( $row['meta_value'] );

			if ( $i === 0 ) {
				$values_sql[] = "SELECT {$post_id} AS $id_column_name, '{$meta_key}' AS meta_key, '{$meta_value}' AS meta_value";
			} else {
				$values_sql[] = "SELECT {$post_id}, '{$meta_key}', '{$meta_value}'";
			}
		}
		$values_union_sql = implode( " UNION ALL\n", $values_sql );

		//phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query(
			"
				UPDATE $table_name pm JOIN ( {$values_union_sql} ) AS new_values
				ON pm.$id_column_name = new_values.$id_column_name AND pm.meta_key = new_values.meta_key
				SET pm.meta_value = new_values.meta_value;
			"
		);
		//phpcs:enable
	}

	/**
	 * Inserts new metas.
	 *
	 * @param array  $meta_updates Array of object metas to update.
	 * @param string $object_type  Object type. Either of 'post', 'term' or 'user'.
	 *
	 * @return void
	 */
	private function insert_new_metas( $meta_updates, $object_type ) {
		global $wpdb;
		$id_column_name   = "{$object_type}_id";
		$where_conditions = [];

		foreach ( $meta_updates as $update ) {
			$where_conditions[] = $wpdb->prepare(
				'( %i = %d AND meta_key = %s)',
				$id_column_name,
				$update[ $id_column_name ],
				$update['meta_key']
			);
		}

		$table_name     = $this->get_table_name( $object_type );
		$existing_metas = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT %i, meta_key FROM %i WHERE ' . implode( ' OR ', $where_conditions ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$id_column_name,
				$table_name
			)
		);

		foreach ( $meta_updates as $key => $update ) {
			$metas_with_matching_post_id = array_filter(
				$existing_metas,
				function ( $row ) use ( $update, $id_column_name ) {
					return $row->{$id_column_name} === $update[ $id_column_name ];
				}
			);

			$meta_key_exists = false !== array_search( $update['meta_key'], array_column( $metas_with_matching_post_id, 'meta_key' ), true );
			if ( $meta_key_exists ) {
				unset( $meta_updates[ $key ] );
				continue;
			}

			$meta_updates[ $key ] = $wpdb->prepare(
				'(%d, %s, %s)',
				$update[ $id_column_name ],
				$update['meta_key'],
				$update['meta_value']
			);
		}
		if ( empty( $meta_updates ) ) {
			return;
		}
		$wpdb->query(
			$wpdb->prepare(
				'INSERT INTO %i ( %i, meta_key, meta_value ) VALUES ' . implode( ',', $meta_updates ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$table_name,
				$id_column_name
			)
		);
	}

	/**
	 * Get row data.
	 * Returns false if the line number is 0 or if the CSV structure is not validated.
	 *
	 * @param int $line_number Line number.
	 *
	 * @return array|false
	 */
	public function get_row_data( $line_number ) {
		$file = get_option( 'rank_math_csv_import' );
		if ( ! $file ) {
			$this->add_error( esc_html__( 'Missing import file.', 'rank-math-pro' ), 'missing_file' );
			CSV_Import_Export::cancel_import( true );
			return false;
		}

		$headers = $this->get_column_headers( $file );
		if ( empty( $headers ) ) {
			$this->add_error( esc_html__( 'Missing CSV headers.', 'rank-math-pro' ), 'missing_headers' );
			return false;
		}

		$required_columns = [ 'id', 'object_type', 'slug' ];
		if ( count( array_intersect( $headers, $required_columns ) ) !== count( $required_columns ) ) {
			$this->add_error( esc_html__( 'Missing one or more required columns.', 'rank-math-pro' ), 'missing_required_columns' );
			return false;
		}

		$raw_data = $this->get_line( $file, $line_number );
		if ( empty( $raw_data ) ) {
			$total_lines = (int) get_option( 'rank_math_csv_import_total' );

			// Last line can be empty, that is not an error.
			if ( $line_number !== $total_lines ) {
				$this->add_error( esc_html__( 'Empty column data.', 'rank-math-pro' ), 'missing_data' );
				$this->row_failed( $line_number );
			}

			return false;
		}

		$csv_separator = apply_filters( 'rank_math/csv_import/separator', ',' );
		$decoded       = str_getcsv( $raw_data, $csv_separator, '"', '\\' );
		if ( count( $headers ) !== count( $decoded ) ) {
			$this->add_error( esc_html__( 'Columns number mismatch.', 'rank-math-pro' ), 'columns_number_mismatch' );
			$this->row_failed( $line_number );
			return false;
		}

		$data = array_combine( $headers, $decoded );
		if ( ! in_array( $data['object_type'], array_keys( CSV_Import_Export::get_possible_object_types() ), true ) ) {
			$this->add_error( esc_html__( 'Unknown object type.', 'rank-math-pro' ), 'unknown_object_type' );
			$this->row_failed( $line_number );
			return false;
		}

		return $data;
	}

	/**
	 * Import specified line.
	 *
	 * @param int $line_number Selected line number.
	 * @return void
	 */
	public function import_line( $line_number ) {
		$data = $this->get_row_data( $line_number );
		if ( ! $data ) {
			return;
		}

		new Import_Row( $data, $this->settings );
		$this->row_imported( $line_number );
	}

	/**
	 * Get term ID from slug.
	 *
	 * @param string $term_slug Term slug.
	 * @return int
	 */
	public static function get_term_id( $term_slug ) {
		global $wpdb;

		if ( ! empty( self::$term_ids[ $term_slug ] ) ) {
			return self::$term_ids[ $term_slug ];
		}

		self::$term_ids[ $term_slug ] = DB_Helper::get_var(
			$wpdb->prepare( "SELECT term_id FROM {$wpdb->terms} WHERE slug = %s", $term_slug )
		);

		return self::$term_ids[ $term_slug ];
	}

	/**
	 * After each batch is finished.
	 *
	 * @param array $items Processed items.
	 */
	public function batch_done( $items ) { // phpcs:ignore
		unset( $this->spl );

		$status = (array) get_option( 'rank_math_csv_import_status', [] );
		if ( ! isset( $status['errors'] ) || ! is_array( $status['errors'] ) ) {
			$status['errors'] = [];
		}
		if ( ! isset( $status['failed_rows'] ) || ! is_array( $status['failed_rows'] ) ) {
			$status['failed_rows'] = [];
		}
		if ( ! isset( $status['imported_rows'] ) || ! is_array( $status['imported_rows'] ) ) {
			$status['imported_rows'] = [];
		}

		$status['imported_rows'] = array_merge( $status['imported_rows'], $this->get_imported_rows() );

		$errors = $this->get_errors();
		if ( $errors ) {
			$status['errors']      = array_merge( $status['errors'], $errors );
			$status['failed_rows'] = array_merge( $status['failed_rows'], $this->get_failed_rows() );
		}

		update_option( 'rank_math_csv_import_status', $status );
	}

	/**
	 * Set row import status.
	 *
	 * @param int $row Row index.
	 */
	private function row_failed( $row ) {
		$this->failed_rows[] = $row + 1;
	}

	/**
	 * Set row import status.
	 *
	 * @param int $row Row index.
	 */
	private function row_imported( $row ) {
		$this->imported_rows[] = $row + 1;
	}

	/**
	 * Get failed rows array.
	 *
	 * @return array
	 */
	private function get_failed_rows() {
		return $this->failed_rows;
	}

	/**
	 * Get failed rows array.
	 *
	 * @return array
	 */
	private function get_imported_rows() {
		return $this->imported_rows;
	}

	/**
	 * Get all import errors.
	 *
	 * @return mixed Array of errors or false if there is no error.
	 */
	public function get_errors() {
		return empty( $this->errors ) ? false : $this->errors;
	}

	/**
	 * Add import error.
	 *
	 * @param string $message Error message.
	 * @param int    $code    Error code.
	 */
	public function add_error( $message, $code = null ) {
		if ( is_null( $code ) ) {
			$this->errors[] = $message;
			return;
		}
		$this->errors[ $code ] = $message;
	}
}
