<?php /** @noinspection SqlNoDataSourceInspection */

namespace Inpsyde\SearchReplace\Database;

/**
 * Class Exporter
 *
 * @property bool|resource fb
 * @package Inpsyde\SearchReplace\Database
 */
class Exporter {

	/**
	 * Stores all error messages in a WP_Error Object
	 */
	private $errors;

	/**
	 * @string  The Path to the Backup Directory
	 */
	private $backup_dir;

	/**
	 * @var Replace
	 */
	private $replace;

	/**
	 * @var Manager
	 */
	private $dbm;

	/**
	 * Count of rows to be replaced at a time
	 *
	 * @var int
	 */
	private $page_size = 100;

	/**
	 * Stores the filename of the backup file
	 */
	private $backup_filename;

	/**
	 * Store file path.
	 *
	 * @var $fp
	 */
	private $fp;

	/**
	 * Store csv data
	 *
	 * @var array
	 */
	private $csv_data = [];

	/**
	 * Exporter constructor.
	 *
	 * @param Replace $replace
	 * @param Manager $dbm
	 * @param \WP_Error $wp_error
	 */
	public function __construct( Replace $replace, Manager $dbm, \WP_Error $wp_error ) {

		$this->errors     = $wp_error;
		$this->backup_dir = get_temp_dir();
		$this->replace    = $replace;
		$this->dbm        = $dbm;
	}

	/**
	 * Write to the backup file
	 *
	 * @param string $search
	 * @param string $replace
	 * @param array $tables The array of table names that should be exported.
	 * @param bool $domain_replace If set, exporter will change the domain name without leading http:// in table
	 *                               wp_blogs if we are on a multisite
	 * @param string $new_table_prefix
	 * @param null $csv
	 *
	 * @return array $report    $report [ 'filename'] : Name of Backup file,
	 *                          $report[ 'errors'] : WP_Error_object,
	 * $report ['changes'] : Array with replacements in tables
	 * @throws \Throwable
	 */
	public function db_backup(
		$search = '',
		$replace = '',
		$tables = [],
		$domain_replace = false,
		$new_table_prefix = '',
		$csv = null
	) {

		if ( count( $tables ) < 1 ) {
			$tables = $this->dbm->get_tables();
		}

		$report = [
			'errors'        => null,
			'changes'       => [],
			'tables'        => '0',
			'changes_count' => '0',
			'filename'      => '',
		];

		$table_prefix = $this->dbm->get_base_prefix();

		// wp_blogs needs special treatment in multisite domain replace, we need to check later if we are working on it.
		$wp_blogs_table = $table_prefix . 'blogs';

		$this->backup_filename = $new_table_prefix === '' ?
			DB_NAME . "_$table_prefix.sql" :
			DB_NAME . "_$new_table_prefix.sql";

		// If the directory for the backup isn't writable, don't proceed.
		// @ToDo Use WP_Filesystem() to access to the file system.
		if ( ! is_writable( $this->backup_dir ) ) {
			$this->errors->add( 9, esc_attr__( 'The backup directory is not writable!', 'search-and-replace' ) );

			return $report;
		}

		$this->fp = $this->open( $this->backup_dir . $this->backup_filename );

		if ( ! $this->fp ) {
			$this->errors->add(
				8,
				esc_attr__( 'Could not open the backup file for writing!', 'search-and-replace' )
			);

			return $report;
		}

		// Begin new backup of MySql get charset. if not set assume utf8.
		$charset = ( defined( 'DB_CHARSET' ) ? DB_CHARSET : 'utf8mb4' );

		$this->stow( '# ' . esc_attr__( 'WordPress MySQL database backup', 'search-and-replace' ) . "\n" );
		$this->stow( "#\n" );
		$this->stow( '# ' . sprintf( __( 'Generated: %s', 'search-and-replace' ), date( 'l j. F Y H:i T' ) ) . "\n" );
		$this->stow( '# ' . sprintf( __( 'Hostname: %s', 'search-and-replace' ), DB_HOST ) . "\n" );
		$this->stow( '# ' . sprintf( __( 'Database: %s', 'search-and-replace' ), $this->backquote( DB_NAME ) ) . "\n" );

		if ( '' !== $new_table_prefix ) {
			$this->stow(
				'# ' . sprintf(
				/* translators: $1 and $2 are the name of the database. */
					__( 'Changed table prefix: From %1$s to %2$s ', 'search-and-replace' ),
					$table_prefix,
					$new_table_prefix
				)
				. "\n"
			);
		}

		$this->stow( "# --------------------------------------------------------\n" );
		$this->stow( "/*!40101 SET NAMES $charset */;\n" );
		$this->stow( "# --------------------------------------------------------\n" );

		foreach ( $tables as $table ) {

			// Count tables.
			$report ['tables'] ++;

			/**
			 * Check if we are replacing the domain in a multisite.
			 * If so, we replace in wp_blogs the stripped url without http(s), because the domains
			 * are stored without http://
			 */
			if ( $table === $wp_blogs_table && $domain_replace && is_multisite() ) {
				$stripped_url_search  = substr( $search, strpos( $search, '/' ) + 2 );
				$stripped_url_replace = substr( $replace, strpos( $replace, '/' ) + 2 );

				// Backup table.
				$table_report = $this->backup_table(
					$stripped_url_search,
					$stripped_url_replace,
					$table,
					$new_table_prefix
				);
			} else {
				// Backup table.
				$table_report = $this->backup_table( $search, $replace, $table, $new_table_prefix, $csv );
			}

			// Log changes if any.
			if ( 0 !== $table_report['change'] ) {
				$report['changes'][ $table ] = $table_report;

				$report ['changes_count'] += $table_report['change'];
			}
		}

		$this->close( $this->fp );

		// Return errors if any.
		if ( count( $this->errors->get_error_codes() ) ) {
			$report['errors'] = $this->errors;
		}

		$report ['filename'] = $this->backup_filename;

		return $report;
	}

	/**
	 * Open Resource
	 *
	 * @param string $filename
	 * @param string $mode
	 *
	 * @return bool|resource
	 */
	private function open( $filename = '', $mode = 'wb' ) {

		if ( '' === $filename ) {
			return false;
		}

		return @fopen( $filename, $mode );
	}

	/**
	 * writes a line to the backup file
	 *
	 * @param $query_line
	 */
	private function stow( $query_line ) {

		if ( @fwrite( $this->fp, $query_line ) === false ) {
			$this->errors->add(
				4,
				sprintf(
					esc_attr__(
						'There was an error writing a line to the backup script: %s',
						'search-and-replace'
					),
					(int) $query_line
				)
			);
		}
	}

	/**
	 * Back Quote
	 *
	 * Add backquotes to tables and db-names in
	 * SQL queries. Taken from phpMyAdmin.
	 *
	 * @param $a_name
	 *
	 * @return array|string
	 */
	private function backquote( $a_name ) {

		if ( ! empty( $a_name ) && $a_name !== '*' ) {
			if ( is_array( $a_name ) ) {
				$result = [];
				reset( $a_name );
				foreach ( $a_name as $key => $val ) {
				// while ( list( $key, $val ) = each( $a_name ) ) {
					$result[ $key ] = '`' . $val . '`';
				}

				return $result;
			}

			return '`' . $a_name . '`';
		}

		return $a_name;
	}

	/**
	 * Backup Table
	 *
	 * Taken partially from phpMyAdmin and partially from
	 * Alain Wolf, Zurich - Switzerland
	 *
	 * Website: http://restkultur.ch/personal/wolf/scripts/db_backup/
	 * Modified by Scott Merrill (http://www.skippy.net/) to use the WordPress $wpdb object
	 *
	 * @param string $search
	 * @param string $replace
	 * @param string $table
	 * @param string $new_table_prefix
	 *
	 * @return array $table_report Reports the changes made to the db.
	 * @throws \Throwable
	 */
	public function backup_table( $search = '', $replace = '', $table, $new_table_prefix = '', $csv = null ) {

		$table_report = [
			'table_name' => $table,
			'rows'       => 0,
			'change'     => 0,
			'changes'    => [],
		];

		// Default columns values.
		$defs = [];
		// Integer value container.
		$ints = [];

		// This array is storage for maybe_serialized values. We must prevent deserialization of user supplied content.
		$maybe_serialized = [];
		$binaries         = [];

		// Do we need to replace the prefix?
		$table_prefix = $this->dbm->get_base_prefix();
		$new_table    = $table;

		if ( '' !== $new_table_prefix ) {
			$new_table = $this->get_new_table_name( $table, $new_table_prefix );
		}

		// Create the SQL statements
		$this->stow( '# --------------------------------------------------------' . "\n" );
		$this->stow( '# ' . sprintf( __( 'Table: %s', 'search-and-replace' ), $this->backquote( $new_table ) ) . "\n" );

		// Retrieve table structure.
		$table_structure = $this->dbm->get_table_structure( $table );

		if ( ! $table_structure ) {
			$this->errors->add( 1, __( 'Error getting table details', 'search-and-replace' ) . ": $table" );

			return $table_report;
		}

		$this->stow( "\n\n" );
		$this->stow( "#\n" );
		$this->stow(
			'# ' . sprintf(
				__( 'Delete any existing table %s', 'search-and-replace' ),
				$this->backquote( $new_table )
			) . "\n"
		);
		$this->stow( "#\n" );
		$this->stow( "\n" );
		$this->stow( 'DROP TABLE IF EXISTS ' . $this->backquote( $new_table ) . ';' . "\n" );

		// Table structure
		// Comment in SQL-file
		$this->stow( "\n\n" );
		$this->stow( "#\n" );
		$this->stow(
			'# ' . sprintf(
				__( 'Table structure of table %s', 'search-and-replace' ),
				$this->backquote( $new_table )
			) . "\n"
		);
		$this->stow( "#\n" );
		$this->stow( "\n" );

		/** @var array $create_table */
		$create_table = $this->dbm->get_create_table_statement( $table );

		if ( false === $create_table ) {
			/* translators: $1 is the name of the table */
			$err_msg = sprintf( __( 'Error with SHOW CREATE TABLE for %s.', 'search-and-replace' ), $table );
			$this->errors->add( 2, $err_msg );
			$this->stow( "#\n# $err_msg\n#\n" );
		}

		// Replace prefix if necessary
		if ( '' !== $new_table_prefix ) {
			$create_table[0][1]             = str_replace( $table, $new_table, $create_table[0][1] );
			$table_report['new_table_name'] = $new_table;
		}

		$this->stow( $create_table[0][1] . ' ;' );

		if ( false === $table_structure ) {
			/* translators: $1 is the name of the table */
			$err_msg = sprintf( __( 'Error getting table structure of %s', 'search-and-replace' ), $table );
			$this->errors->add( 3, $err_msg );
			$this->stow( "#\n# $err_msg\n#\n" );
		}

		// Comment in SQL-file
		$this->stow( "\n\n" );
		$this->stow( "#\n" );
		$this->stow(
		/* translators: $1 is the name of the new table */
			'# ' . sprintf( __( 'Data contents of table %s', 'search-and-replace' ), $this->backquote( $new_table ) ) .
			"\n"
		);
		$this->stow( "#\n" );

		foreach ( $table_structure as $struct ) {
			if ( 0 === strpos( $struct->Type, 'tinyint' )
			     || 0 === stripos( $struct->Type, 'smallint' )
			     || 0 === stripos( $struct->Type, 'mediumint' )
			     || 0 === stripos( $struct->Type, 'int' )
			     || 0 === stripos( $struct->Type, 'bigint' )
			) {
				$defs[ strtolower( $struct->Field ) ] = ( null === $struct->Default ) ? 'NULL' : $struct->Default;
				$ints[ strtolower( $struct->Field ) ] = '1';

			} elseif ( 0 === stripos( $struct->Type, 'binary' )
			           || 0 === stripos( $struct->Type, 'varbinary' )
			           || 0 === stripos( $struct->Type, 'blob' )
			           || 0 === stripos( $struct->Type, 'tinyblob' )
			           || 0 === stripos( $struct->Type, 'mediumblob' )
			           || 0 === stripos( $struct->Type, 'longblob' )
			) {
				$binaries[ strtolower( $struct->Field ) ] = 1;
			}

			// Longtext is used for meta_values as best practice in all of the automatic products.
			if ( 0 === stripos( $struct->Type, 'longtext' ) ) {
				$maybe_serialized[] = strtolower( $struct->Field );
			}
		}

		// Split columns array in primary key string and columns array.
		$columns     = $this->dbm->get_columns( $table );
		$primary_key = $columns[0];
		$row_count   = $this->dbm->get_rows( $table );
		$page_size   = $this->page_size;
		$pages       = ceil( $row_count / $page_size );

		// Prepare CSV data.
		if ( $csv !== null ) {
			$csv_lines = explode( "\n", $csv );
			$csv_head  = str_getcsv( 'search,replace' );

			foreach ( $csv_lines as $line ) {
				$csv_line = str_getcsv( $line );
				if ( count( $csv_line ) === 2 ) {
					$this->csv_data[] = array_combine( $csv_head, $csv_line );
				}
			}
		}

		for ( $page = 0; $page < $pages; $page ++ ) {
			$start = $page * $page_size;

			$table_data = $this->dbm->get_table_content( $table, $start, $page_size );

			$entries = 'INSERT INTO ' . $this->backquote( $new_table ) . ' VALUES (';
			// \x08\\x09, not required
			$hex_search  = [ "\x00", "\x0a", "\x0d", "\x1a" ];
			$hex_replace = [ '\0', '\n', '\r', '\Z' ];

			if ( $table_data ) :
				foreach ( $table_data as $row ) :
					$values = [];
					$table_report['rows'] ++;

					foreach ( $row as $column => $value ) :
						// If "change database prefix" is set we have to look for occurrences of the old prefix
						// in the db entries and change them.
						if ( $new_table !== $table ) {
							// Check if column is expected to hold serialized value.
							if ( is_serialized( $value, false )
							     && in_array( strtolower( $column ), $maybe_serialized, true )
							) {
								$value = $this->replace->recursive_unserialize_replace(
									$table_prefix,
									$new_table_prefix,
									$value
								);
							} else {
								$value = str_replace( $table_prefix, $new_table_prefix, $value );
							}
						}

						// Skip replace if no search pattern
						// Check if we need to replace something
						// Skip primary_key
						// Skip `guid` column https://codex.wordpress.org/Changing_The_Site_URL#Important_GUID_Note
						if ( $column !== $primary_key && $column !== 'guid' ) {
							// Initialize
							$edited_data = '';

							if ( '' !== $search ) {
								// Check if column is expected to hold serialized value.
								if ( is_serialized( $value, false )
								     && in_array( strtolower( $column ), $maybe_serialized, true )
								) {
									$edited_data = $this->replace->recursive_unserialize_replace(
										$search,
										$replace,
										$value
									);
								} else {
									$edited_data = str_replace( $search, $replace, $value );
								}
							}

							// If csv string has passed let's replace those values.
							if ( $csv !== null ) {
								foreach ( $this->csv_data as $entry ) {
									$edited_data = is_serialized( $edited_data, false ) ?
										$this->replace->recursive_unserialize_replace(
											$entry['search'],
											$entry['replace'],
											$edited_data
										) : str_replace( $entry['search'], $entry['replace'], $value );
								}
							}

							// When a replace happen, update the table report.
							if ( $edited_data && $edited_data !== $value ) {
								$table_report['change'] ++;

								// log changes
								$table_report['changes'][] = [
									'row'    => $table_report['rows'],
									'column' => $column,
									'from'   => $value,
									'to'     => $edited_data,
								];

								$value = $edited_data;
							}
						}

						if ( isset( $ints[ strtolower( $column ) ] ) ) {
							// make sure there are no blank spots in the insert syntax,
							// yet try to avoid quotation marks around integers
							$value    = ( null === $value || '' === $value ) ? $defs[ strtolower( $column ) ] : $value;
							$values[] = ( '' === $value ) ? "''" : $value;
						} else if ( isset( $binaries[ strtolower( $column ) ] ) ) {
							$hex      = unpack( 'H*', $value );
							$values[] = "0x$hex[1]";
						} else {
							$values[] = "'" . str_replace(
									$hex_search,
									$hex_replace,
									$this->sql_addslashes( $value )
								) . "'";
						}
					endforeach;

					$this->stow( " \n" . $entries . implode( ', ', $values ) . ');' );

				endforeach;
			endif;
		}

		// Create footer/closing comment in SQL-file
		$this->stow( "\n" );
		$this->stow( "#\n" );
		$this->stow(
			'# ' . sprintf(
			/* translators: $1 is the name of the table */
				__( 'End of data contents of table %s', 'search-and-replace' ),
				$this->backquote( $new_table )
			) . "\n"
		);
		$this->stow( "# --------------------------------------------------------\n" );
		$this->stow( "\n" );

		return $table_report;
	}

	/**
	 * Get new Table name
	 *
	 * strips the current table prefix and adds a new one provided in $new_table_prefix
	 *
	 * @param $table
	 * @param $new_table_prefix
	 *
	 * @return string  The table name with new prefix
	 */
	private function get_new_table_name( $table, $new_table_prefix ) {

		// Get length of base_prefix
		$prefix        = $this->dbm->get_base_prefix();
		$prefix_length = strlen( $prefix );
		// Strip old prefix
		$part_after_prefix = substr( $table, $prefix_length );

		// Build new table name
		return $new_table_prefix . $part_after_prefix;
	}

	/**
	 * Better addslashes for SQL queries.
	 * Taken from phpMyAdmin.
	 *
	 * @param string $a_string
	 * @param bool $is_like
	 *
	 * @return mixed
	 */
	private function sql_addslashes( $a_string = '', $is_like = false ) {

		if ( $is_like ) {
			$a_string = str_replace( '\\', '\\\\\\\\', $a_string );
		} else {
			$a_string = str_replace( '\\', '\\\\', $a_string );
		}

		return str_replace( '\'', '\\\'', $a_string );
	}

	/**
	 * Close Resource
	 *
	 * @param $fp
	 */
	private function close( $fp ) {

		fclose( $fp );
	}

	/**
	 * Deliver
	 *
	 * @param string $filename The name of the file to be downloaded.
	 * @param bool $compress If TRUE, gz compression is used.
	 *
	 * @return bool FALSE if error , has to DIE when file is delivered
	 */
	public function deliver_backup( $filename = '', $compress = false ) {

		if ( '' === $filename ) {
			return false;
		}

		// Build the file path.
		$diskfile = $this->backup_dir . $filename;

		// Let know the user why we cannot download his file.
		if ( ! file_exists( $diskfile ) ) {
			wp_die(
				esc_html__( 'Seems was not possible to create the file for some reason.', 'search-and-replace' ),
				esc_html__( 'Cannot Process the file - Search &amp; Replace', 'search-and-replace' ),
				[
					'back_link' => true,
				]
			);
		}

		// Compress file if set.
		if ( $compress ) {
			// Gzipping may eat into memory.
			$this->increase_memory();

			$diskfile = $this->gzip( $diskfile );
		}

		// Provide file for download.
		header( 'Content-Type: application/force-download' );
		header( 'Content-Type: application/octet-stream' );
		header( 'Content-Length: ' . filesize( $diskfile ) );
		header( 'Content-Disposition: attachment; filename=' . basename( $diskfile ) );

		readfile( $diskfile );

		die();
	}

	/**
	 * Increase Memory
	 *
	 * @return void
	 */
	private function increase_memory() {

		// Try upping the memory limit before gzipping.
		if ( function_exists( 'memory_get_usage' ) && ( (int) @ini_get( 'memory_limit' ) < 64 ) ) {
			@ini_set( 'memory_limit', '64M' );
		}
	}

	/**
	 * Gzip
	 *
	 * @param string $diskfile The path of the file to compress
	 *
	 * @return string the file path compressed or not
	 */
	private function gzip( $diskfile ) {

		// The file to serve.
		$gz_diskfile = "{$diskfile}.gz";

		// Always serve a fresh file.
		// If file all-ready exists doesn't mean we have the same replace request.
		file_exists( $gz_diskfile ) && unlink( $gz_diskfile );

		// Try gzipping with an external application.
		@exec( "gzip $diskfile" );

		if ( file_exists( $gz_diskfile ) ) {
			$diskfile = $gz_diskfile;
		}

		// If we are not capable of using `gzip` command, lets try something else.
		if ( $diskfile !== $gz_diskfile && function_exists( 'gzencode' ) ) {
			$text     = file_get_contents( $diskfile );
			$gz_text  = gzencode( $text, 9 );
			$this->fb = fopen( $gz_diskfile, 'wb' );

			fwrite( $this->fp, $gz_text );

			// Don't serve gzipped file if actually we encounter problem to close it.
			if ( fclose( $this->fp ) ) {
				unlink( $diskfile );

				$diskfile = $gz_diskfile;
			}
		}

		return $diskfile;
	}
}
