<?php namespace ProcessWire;

/**
 * #pw-summary ProcessWire Database Backup and Restore
 * #pw-summary-initialization It’s not typically necessary to call these initialization methods unless doing manual initialization.
 * #pw-var $backup
 * #pw-instantiate $backup = $database->backups();
 * #pw-order-groups actions,reporting,initialization,advanced
 * #pw-body = 
 * This class intentionally does not have any external dependencies (other than PDO)
 * so that it can be included by outside tools for restoring/exporting, with the main
 * example of that being the ProcessWire installer. 
 * 
 * The recommended way to access these backup methods is via the `$database` API variable
 * method `$database->backups()`, which returns a `WireDatabaseBackup` instance, however
 * you can also initialize the class manually if you prefer, like this:
 * ~~~~~
 * // determine where backups will go (should NOT be web accessible)
 * $backupPath = $config->paths->assets . 'backups/';
 * 
 * // create a new WireDatabaseBackup instance
 * $backup = new WireDatabaseBackup($backupPath);
 * 
 * // Option 1: set the already-connected DB connection
 * $backup->setDatabase($this->database); 
 * 
 * // Option 2: OR provide a Config object that contains the DB connection info
 * $backup->setDatabaseConfig($this->config); 
 * 
 * ~~~~~
 * ### Backup the database
 * ~~~~~
 * $file = $backup->backup();
 * if($file) {
 *   echo "Backed up to: $file";
 * } else {
 *   echo "Backup failed: " . implode("<br>", $backup->errors());
 * }
 * ~~~~~
 * 
 * ### Restore a database
 * ~~~~~
 * $success = $backup->restore($file);
 * if($success) {
 *   echo "Restored database from file: $file";
 * } else {
 *   echo "Restore failed: " . implode("<br>", $backup->errors());
 * }
 * ~~~~~
 * #pw-body
 *
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 * 
 *
 */

class WireDatabaseBackup {
	
	const fileHeader = '--- WireDatabaseBackup';
	const fileFooter = '--- /WireDatabaseBackup';

	/**
	 * ProcessWire instance, when applicable
	 * 
	 * @var ProcessWire
	 * 
	 */
	protected $wire = null;

	/**
	 * Options available for the $options argument to backup() method
	 * 
	 * @var array
	 * 
	 */
	protected $backupOptions = array(

		// filename for backup: default is to make a dated filename, but this can also be used (basename only, no path)
		'filename' => '',
		
		// optional description of this backup
		'description' => '',
		
		// if specified, export will only include these tables
		'tables' => array(),
		
		// username to associate with the backup file (string), optional
		'user' => '',
		
		// exclude creating or inserting into these tables
		'excludeTables' => array(),
		
		// exclude creating these tables, but still export data (not supported by mysqldump)
		'excludeCreateTables' => array(),
		
		// exclude exporting data, but still create tables (not supported by mysqldump)
		'excludeExportTables' => array(),
		
		// SQL conditions for export of individual tables (table => array(SQL conditions))
		// The 'table' portion (index) may also be a full PCRE regexp, must start with '/' to be recognized as regex
		'whereSQL' => array(),
		
		// max number of seconds allowed for execution 
		'maxSeconds' => 1200,
		
		// use DROP TABLES statements before CREATE TABLE statements?
		'allowDrop' => true,
		
		// use UPDATE ON DUPLICATE KEY so that INSERT statements can UPDATE when rows already present (all tables)
		'allowUpdate' => false,
		
		// table names that will use UPDATE ON DUPLICATE KEY (does NOT require allowUpdate=true)
		'allowUpdateTables' => array(),
		
		// find and replace in row data during backup (not supported by exec/mysql method)
		'findReplace' => array(
			// Example: 'databass' => 'database'
		),
		
		// find and replace in create table statements (not supported by exec/mysqldump)
		'findReplaceCreateTable' => array( 
			// Example: 'DEFAULT CHARSET=latin1;' => 'DEFAULT CHARSET=utf8;', 
		),
	
		// additional SQL queries to append at the bottom
		'extraSQL' => array(
			// Example: UPDATE pages SET CREATED=NOW	
		),
		
		// EXEC MODE IS CURRRENTLY EXPERIMENTAL AND NOT RECOMMEND FOR USE YET
		// if true, we will try to use mysqldump (exec) first. if false, we won't attempt mysqldump.
		'exec' => false, 
		
		// exec command to use for mysqldump (when in use)
		'execCommand' => '[dbPath]mysqldump 
			--complete-insert=TRUE 
			--add-locks=FALSE 
			--disable-keys=FALSE 
			--extended-insert=FALSE 
			--default-character-set=utf8 
			--comments=FALSE 
			--compact 
			--skip-disable-keys 
			--skip-add-locks 
			--add-drop-table=TRUE 
			--result-file=[dbFile]
			--port=[dbPort] 
			-u[dbUser] 
			-p[dbPass] 
			-h[dbHost] 
			[dbName]
			[tables]' 
		);

	/**
	 * Options available for the $options argument to restore() method
	 * 
	 * @var array
	 * 
	 */
	protected $restoreOptions = array(
		
		// table names to restore (empty=all)
		'tables' => array(),
		
		// allow DROP TABLE statements?
		'allowDrop' => true, 
	
		// DROP ALL tables before restore? (requires that 'allowDrop' must also be true)
		'dropAll' => false, 
		
		// halt execution when an error occurs?
		'haltOnError' => false,
		
		// max number of seconds allowed for execution
		'maxSeconds' => 1200,
		
		// find and replace in row data (not supported by exec/mysql method)
		'findReplace' => array( 
			// Example: 'databass' => 'database'
		),
		
		// find and replace in create table statements (not supported by exec/mysql)
		'findReplaceCreateTable' => array( 
			// Example: 'DEFAULT CHARSET=latin1;' => 'DEFAULT CHARSET=utf8;', 
		),

		// EXEC MODE IS CURRRENTLY EXPERIMENTAL AND NOT RECOMMEND FOR USE YET
		// if true, we will try to use mysql via exec first (faster). if false, we won't attempt that.
		'exec' => false, 
		
		// command to use for mysql exec
		'execCommand' => '[dbPath]mysql 
			--port=[dbPort] 
			-u[dbUser] 
			-p[dbPass] 
			-h[dbHost] 
			[dbName] < [dbFile]',
		);
	
	/**
	 * @var null|\PDO
	 * 
	 */
	protected $database = null;

	/**
	 * @var array
	 * 
	 */
	protected $databaseConfig = array(
		'dbUser' => '',
		'dbPass' => '', // optional (if password is blank)
		'dbHost' => '', 
		'dbPort' => '', 
		'dbName' => '', 
		'dbPath' => '', // optional mysql/mysqldump path on file system
		'dbSocket' => '', 
		'dbCharset' => 'utf8',
	);

	/**
	 * Array of text indicating details about what methods were used (primarily for debugging)
	 * 
	 * @var array
	 * 
	 */
	protected $notes = array();
	
	/**
	 * Array of text error messages
	 *
	 * @var array
	 *
	 */
	protected $errors = array();

	/**
	 * Database files path
	 * 
	 * @var string|null
	 * 
	 */
	protected $path = null;

	/**
	 * Cache for getAllTables()
	 * 
	 * @var array
	 * 
	 */
	protected $tables = array();
	
	/**
	 * Cache for getAllTables()
	 *
	 * @var array
	 *
	 */
	protected $counts = array();

	/**
	 * Construct
	 * 
	 * You should follow-up the construct call with one or both of the following:
	 * 
	 * 	- $backups->setDatabase(PDO|WireDatabasePDO);
	 * 	- $backups->setDatabaseConfig(array|object);
	 * 
	 * #pw-group-initialization
	 * 
	 * @param string $path Path where database files are stored
	 * @throws \Exception
	 * 
	 */
	public function __construct($path = '') {
		if(strlen($path)) $this->setPath($path);
	}

	/**
	 * Set the current ProcessWire instance
	 * 
	 * #pw-internal
	 * 
	 * @param ProcessWire $wire
	 * 
	 */
	public function setWire($wire) {
		if(is_object($wire) && $wire->className() == 'ProcessWire') $this->wire = $wire;
	}

	/**
	 * Set the database configuration information
	 * 
	 * #pw-group-initialization
	 * 
	 * @param array|Config|object $config Containing these properties: 
	 *  - dbUser
	 *  - dbHost
	 *  - dbPort
	 *  - dbName
	 * 	- dbPass
	 *  - dbPath (optional)
	 *  - dbCharset (optional)
	 * @return $this
	 * @throws \Exception if missing required config settings
	 * 
	 */
	public function setDatabaseConfig($config) {
		
		foreach($this->databaseConfig as $key => $_value) {
			if(is_object($config) && isset($config->$key)) $value = $config->$key;
				else if(is_array($config) && isset($config[$key])) $value = $config[$key]; 
				else $value = '';
			if(empty($value) && !empty($_value)) $value = $_value; // i.e. dbCharset
			if($key == 'dbPath' && $value) {
				$value = rtrim($value, '/') . '/';
				if(!is_dir($value)) $value = '';
			}
			$this->databaseConfig[$key] = $value;
		}

		$missing = array();
		$optional = array('dbPass', 'dbPath', 'dbSocket', 'dbPort'); 
		foreach($this->databaseConfig as $key => $value) {
			if(empty($value) && !in_array($key, $optional)) $missing[] = $key;
		}

		if(count($missing)) {
			throw new \Exception("Missing required config for: " . implode(', ', $missing));
		}
		
		// $charset = $this->databaseConfig['dbCharset'];
		// $this->backupOptions['findReplaceCreateTable']['DEFAULT CHARSET=latin1;'] = "DEFAULT CHARSET=$charset;";

		return $this;
	}

	/**
	 * Set the PDO database connection
	 * 
	 * #pw-group-initialization
	 * 
	 * @param \PDO|WireDatabasePDO $database
	 * @throws \PDOException on invalid connection
	 * 
	 */
	public function setDatabase($database) {
		$query = $database->prepare('SELECT DATABASE()'); 
		$query->execute();
		list($dbName) = $query->fetch(\PDO::FETCH_NUM); 
		if($dbName) $this->databaseConfig['dbName'] = $dbName; 
		$this->database = $database;
	}

	/**
	 * Get current database connection, initiating the connection if not yet active
	 * 
	 * #pw-advanced
	 * 
	 * @return \PDO
	 * @throws \Exception
	 * 
	 */
	public function getDatabase() {
		
		if($this->database) return $this->database; 
		
		$config = $this->databaseConfig; 
		if(empty($config['dbUser'])) throw new \Exception("Please call setDatabaseConfig(config) to supply config information so we can connect."); 
		
		if($config['dbSocket']) {
			$dsn = "mysql:unix_socket=$config[dbSocket];dbname=$config[dbName];";
		} else {
			$dsn = "mysql:dbname=$config[dbName];host=$config[dbHost]";
			if($config['dbPort']) $dsn .= ";port=$config[dbPort]";
		}
		
		$options = array(
			\PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES '$config[dbCharset]'",
			\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
		);
		
		$database = new \PDO($dsn, $config['dbUser'], $config['dbPass'], $options);
		$this->setDatabase($database); 
		return $database;
	}

	/**
	 * Add an error and return last error
	 * 
	 * #pw-group-reporting
	 * 
	 * @param string $str If omitted, no error is added
	 * @return string
	 * 
	 */
	public function error($str = '') {
		if(strlen($str)) $this->errors[] = $str; // append error message
		return count($this->errors) ? end($this->errors) : ''; // return last error
	}

	/**
	 * Return all error messages that occurred
	 * 
	 * #pw-group-reporting
	 * 
	 * @param bool $reset Specify true to clear out existing errors or omit just to return error messages
	 * @return array
	 * 
	 */
	public function errors($reset = false) {
		$errors = $this->errors; 	
		if($reset) $this->errors = array();
		return $errors;
	}
	
	/**
	 * Record a note
	 * 
	 * #pw-group-reporting
	 *
	 * @param $key
	 * @param $value
	 *
	 */
	protected function note($key, $value) {
		if(!empty($this->notes[$key])) $this->notes[$key] .= ", $value";
			else $this->notes[$key] = $value;
	}

	/**
	 * Get all notes
	 * 
	 * #pw-group-reporting
	 *
	 * @param bool $reset
	 * @return array
	 *
	 */
	public function notes($reset = false) {
		$notes = $this->notes;
		if($reset) $this->notes = array();
		return $notes;
	}

	/**
	 * Set path where database files are stored
	 * 
	 * #pw-group-initialization
	 * 
	 * @param string $path
	 * @return $this
	 * @throws \Exception if path has a problem
	 * 
	 */
	public function setPath($path) {
		$path = $this->sanitizePath($path); 
		if(!is_dir($path)) throw new \Exception("Path doesn't exist: $path");
		if(!is_writable($path)) throw new \Exception("Path isn't writable: $path");
		$this->path = $path;
		return $this;
	}

	/**
	 * Get path where database files are stored
	 *
	 * #pw-group-reporting
	 *
	 * @return string
	 *
	 */
	public function getPath() {
		return $this->path; 
	}
	
	/**
	 * Return array of all backup files
	 *
	 * To get additional info on any of them, call getFileInfo($basename) method
	 * 
	 * #pw-group-reporting
	 *
	 * @param bool $getObjects Get SplFileInfo objects rather than basenames? (3.0.214+)
	 * @return array|\SplFileInfo[] Array of strings (basenames), or array of SplFileInfo objects (when requested)
	 *
	 */
	public function getFiles($getObjects = false) {
		$dir = new \DirectoryIterator($this->path);
		$files = array();
		foreach($dir as $file) {
			if($file->isDot() || $file->isDir()) continue;
			$key = $file->getMTime();
			while(isset($files[$key])) $key++;
			$files[$key] = ($getObjects ? $file : $file->getBasename());
		}
		krsort($files); // sort by date, newest to oldest
		return array_values($files); 
	}
	
	/**
	 * Get information about a backup file
	 * 
	 * #pw-group-reporting
	 *
	 * @param string $filename
	 * @return array Returns associative array of information on success, empty array on failure
	 *
	 */
	public function getFileInfo($filename) {
		
		// all possible info (null values become integers when populated)
		$info = array(
			'description' => '',
			'valid' => false,
			'time' => '', // ISO-8601
			'mtime' => null, // timestamp
			'user' => '',
			'size' => null,
			'basename' => '',
			'pathname' => '',
			'dbName' => '',
			'tables' => array(),
			'excludeTables' => array(),
			'excludeCreateTables' => array(),
			'excludeExportTables' => array(),
			'numTables' => null, 
			'numCreateTables' => null, 
			'numInserts' => null, 
			'numSeconds' => null,
		);
		
		$filename = $this->sanitizeFilename($filename); 
		if(!file_exists($filename)) return array();

		$fp = fopen($filename, 'r');
		
		if($fp === false) {
			$this->error('Unable to open file for reading: ' . basename($filename));
			return array();
		}
		
		$line = fgets($fp);
		if(strpos($line, self::fileHeader) === 0 || strpos($line, "# " . self::fileHeader) === 0) {
			$pos = strpos($line, '{');
			if($pos !== false) {
				$json = substr($line, $pos);
				$info2 = json_decode($json, true);
				if(!$info2) $info2 = array();
				foreach($info2 as $key => $value) $info[$key] = $value;
			}
		}

		$bytes = strlen(self::fileFooter) + 255; // some extra bytes in case something gets added at the end
		fseek($fp, $bytes * -1, SEEK_END); 
		$foot = fread($fp, $bytes); 
		$info['valid'] = strpos($foot, self::fileFooter) !== false; 
		fclose($fp);
	
		// footer summary
		$pos = strpos($foot, self::fileFooter);
		if($pos !== false) $pos += strlen(self::fileFooter);
		if($info['valid'] && $pos !== false) {
			$json = substr($foot, $pos); 
			$summary = json_decode($json, true); 
			if(is_array($summary)) $info = array_merge($info, $summary); 
		}
		
		$info['size'] = filesize($filename); 
		$info['mtime'] = filemtime($filename); 
		$info['pathname'] = $filename; 
		$info['basename'] = basename($filename); 
		
		return $info;
	}

	/**
	 * Get array of all table names
	 * 
	 * #pw-group-reporting
	 *
	 * @param bool $count If true, returns array indexed by name with count of records as value
	 * @param bool $cache Allow use of cache?
	 * @return array
	 *
	 */
	public function getAllTables($count = false, $cache = true) {

		if($cache) {
			if($count && count($this->counts)) return $this->counts;
			if(count($this->tables)) return $this->tables;
		} else {
			$this->tables = array();
			$this->counts = array();
		}

		$query = $this->database->prepare('SHOW TABLES');
		$query->execute();
		/** @noinspection PhpAssignmentInConditionInspection */
		while($row = $query->fetch(\PDO::FETCH_NUM)) $this->tables[$row[0]] = $row[0];
		$query->closeCursor();

		if($count) {
			foreach($this->tables as $table) {
				$query = $this->database->prepare("SELECT COUNT(*) FROM `$table`");
				$query->execute();
				$row = $query->fetch(\PDO::FETCH_NUM);
				$this->counts[$table] = (int) $row[0];
			}
			$query->closeCursor();
			return $this->counts;
			
		} else {
			return $this->tables;
		}
	}

	////////////////////////////////////////////////////////////////////////////////////////////////////////////////

	/**
	 * Perform a database export/dump
	 * 
	 * #pw-group-actions
	 * 
	 * @param array $options Options to modify default behavior:
	 * - `filename` (string): filename for backup: default is to make a dated filename, but this can also be used (basename only, no path)
	 * - `description` (string): optional description of this backup
	 * - `tables` (array): if specified, export will only include these tables
	 * - `user` (string): username to associate with the backup file (string), optional
	 * - `excludeTables` (array): exclude creating or inserting into these tables
	 * - `excludeCreateTables` (array): exclude creating these tables, but still export data
	 * - `excludeExportTables` (array): exclude exporting data, but still create tables
	 * - `whereSQL` (array): SQL conditions for export of individual tables [table => [SQL conditions]]. The `table` portion (index) may also be a full PCRE regexp, must start with `/` to be recognized as regex.
	 * - `maxSeconds` (int): max number of seconds allowed for execution (default=1200)
	 * - `allowDrop` (bool): use DROP TABLES statements before CREATE TABLE statements? (default=true)
	 * - `allowUpdate` (bool): use UPDATE ON DUPLICATE KEY so that INSERT statements can UPDATE when rows already present (all tables). (default=false)
	 * - `allowUpdateTables` (array): table names that will use UPDATE ON DUPLICATE KEY (does NOT require allowUpdate=true)
	 * - `findReplace` (array): find and replace in row data during backup. Example: ['databass' => 'database']
	 * - `findReplaceCreateTable` (array): find and replace in create table statements
	 *    Example: ['DEFAULT CHARSET=latin1;' => 'DEFAULT CHARSET=utf8;']
	 * - `extraSQL` (array): additional SQL queries to append at the bottom. Example: ['UPDATE pages SET created=NOW()']
	 * @return string Full path and filename of database export file, or false on failure.
	 * @throws \Exception on fatal error
	 * @see WireDatabaseBackup::restore()
	 * 
	 */
	public function backup(array $options = array()) {

		if(!$this->path) throw new \Exception("Please call setPath('/backup/files/path/') first"); 
		$this->errors(true); 
		$options = array_merge($this->backupOptions, $options); 
	
		if(empty($options['filename'])) {
			// generate unique filename
			$tail = ((count($options['tables']) || count($options['excludeTables']) || count($options['excludeExportTables'])) ? '-part' : '');
			$n = 0;
			do {
				$options['filename'] = $this->databaseConfig['dbName'] . '_' . date('Y-m-d_H-i-s') . $tail . ($n ? "-$n" : "") . ".sql";
				$n++;
			} while(file_exists($this->path . $options['filename'])); 
		} else {
			$options['filename'] = basename($options['filename']); 
		}
	
		set_time_limit($options['maxSeconds']); 
		$file = false;
		
		if($this->supportsExec($options)) {
			$file = $this->backupExec($this->path . $options['filename'], $options);
			$this->note('method', 'exec_mysqldump'); 
		}
		
		if(!$file) {
			$file = $this->backupPDO($this->path . $options['filename'], $options);
			$this->note('method', 'pdo'); 
		}
		
		$success = false;
		if($file && file_exists($file)) {
			if(!filesize($file)) {
				$this->unlink($file);
			} else {
				$success = true; 
			}
		}
	
		return $success ? $file : false;
	}

	/**
	 * Unlink file using PW if available or PHP if not
	 * 
	 * @param string $file
	 * @return bool
	 * @throws WireException
	 * 
	 */
	protected function unlink($file) {
		if(!is_file($file)) return false;
		if($this->wire) {
			return $this->wire->files->unlink($file, true);
		} else {
			return unlink($file); 
		}
	}

	/**
	 * Set backup options
	 * 
	 * #pw-internal
	 * 
	 * @param array $options
	 * @return $this
	 * 
	 */
	public function setBackupOptions(array $options) {
		$this->backupOptions = array_merge($this->backupOptions, $options); 
		return $this;
	}

	/**
	 * Start a new backup file, adding our info header to the top
	 * 
	 * @param string $file
	 * @param array $options
	 * @return bool
	 * 
	 */
	protected function backupStartFile($file, array $options) {

		$fp = fopen($file, 'w+'); 
		
		if(!$fp) {
			$this->error("Unable to write header to file: $file"); 
			return false;
		}
		
		$info = array(
			'time' => date('Y-m-d H:i:s'), 
			'user' => $options['user'],
			'dbName' => $this->databaseConfig['dbName'],
			'description' => $options['description'], 
			'tables' => $options['tables'], 
			'excludeTables' => $options['excludeTables'],
			'excludeCreateTables' => $options['excludeCreateTables'], 
			'excludeExportTables' => $options['excludeExportTables'],
		);
		
		$json = json_encode($info); 
		$json = str_replace(array("\r", "\n"), " ", $json);
		
		fwrite($fp, "# " . self::fileHeader . " $json\n"); 
		fclose($fp);
		if($this->wire) $this->wire->files->chmod($file);
		return true; 
	}

	/**
	 * End a new backup file, adding our footer to the bottom
	 *
	 * @param string|resource $file
	 * @param array $summary
	 * @param array $options
	 * @return bool
	 *
	 */
	protected function backupEndFile($file, array $summary = array(), array $options = array()) {

		$fp = is_resource($file) ? $file : fopen($file, 'a+'); 
		
		if(!$fp) {
			$this->error("Unable to write footer to file: $file");
			return false;
		}
		
		foreach($options['extraSQL'] as $sql) {
			fwrite($fp, "\n" . rtrim($sql, '; ') . ";\n"); 
		}
		
		$footer = "# " . self::fileFooter;
		if(count($summary)) {
			$json = json_encode($summary); 
			$json = str_replace(array("\r", "\n"), " ", $json); 
			$footer .= " $json";
		}

		fwrite($fp, "\n$footer"); 
		fclose($fp); 
		return true; 
	}

	/**
	 * Create a mysql dump file using PDO
	 *
	 * @param string $file Path + filename to create
	 * @param array $options
	 * @return string|bool Returns the created file on success or false on error
	 *
	 */
	protected function backupPDO($file, array $options = array()) {

		$database = $this->getDatabase();
		$options = array_merge($this->backupOptions, $options);
		if(!$this->backupStartFile($file, $options)) return false;
		$startTime = time();
		$fp = fopen($file, "a+"); 
		$tables = $this->getAllTables();
		$numCreateTables = 0;
		$numTables = 0;
		$numInserts = 0;
		$hasReplace = count($options['findReplace']); 

		foreach($tables as $table) {

			if(in_array($table, $options['excludeTables'])) continue;
			if(count($options['tables']) && !in_array($table, $options['tables'])) continue;

			if(in_array($table, $options['excludeCreateTables'])) {
				// skip
			} else {
				if($options['allowDrop']) fwrite($fp, "\nDROP TABLE IF EXISTS `$table`;");
				$query = $database->prepare("SHOW CREATE TABLE `$table`");
				$query->execute();
				$row = $query->fetch(\PDO::FETCH_NUM);
				$createTable = $row[1]; 
				foreach($options['findReplaceCreateTable'] as $find => $replace) {
					$createTable = str_replace($find, $replace, $createTable); 
				}
				$numCreateTables++;
				fwrite($fp, "\n$createTable;\n");
			}
			
			if(in_array($table, $options['excludeExportTables'])) continue; 
			$numTables++;
			$columns = array();
			$query = $database->prepare("SHOW COLUMNS FROM `$table`");
			$query->execute();
			/** @noinspection PhpAssignmentInConditionInspection */
			while($row = $query->fetch(\PDO::FETCH_NUM)) $columns[] = $row[0];
			$query->closeCursor();
			$columnsStr = '`' . implode('`, `', $columns) . '`';

			$sql = "SELECT $columnsStr FROM `$table` ";

			$conditions = array();
			foreach($options['whereSQL'] as $_table => $_conditions) {
				if($_table === $table || ($_table[0] == '/' && preg_match($_table, $table))) $conditions = array_merge($conditions, $_conditions); 
			}
			if(count($conditions)) {
				$sql .= "WHERE ";
				foreach(array_values($conditions) as $n => $condition) {
					if($n) $sql .= "AND ";	
					$sql .= "($condition) ";
				}
			}
			
			$query = $database->prepare($sql);
			$this->executeQuery($query);

			/** @noinspection PhpAssignmentInConditionInspection */
			while($row = $query->fetch(\PDO::FETCH_NUM)) {
				$numInserts++;
				$out = "\nINSERT INTO `$table` ($columnsStr) VALUES(";
				foreach($row as $value) {
					if(is_null($value)) {
						$value = 'NULL';
					} else {
						if($hasReplace) foreach($options['findReplace'] as $find => $replace) {
							if(strpos($value, $find)) $value = str_replace($find, $replace, $value);
						}
						$value = $database->quote($value);
					}
					$out .= "$value, ";
				}
				$out = rtrim($out, ", ") . ") ";
				if($options['allowUpdate']) {
					$out .= "ON DUPLICATE KEY UPDATE ";
					foreach($columns as $c) $out .= "`$c`=VALUES(`$c`), ";
				}

				$out = rtrim($out, ", ") . ";";
				fwrite($fp, $out);
			}

			$query->closeCursor();
			fwrite($fp, "\n");
		}

		$summary = array(
			'numTables' => $numTables, 
			'numCreateTables' => $numCreateTables, 
			'numInserts' => $numInserts,
			'numSeconds' => time() - $startTime, 
		);
		$this->backupEndFile($fp, $summary, $options); // this does the fclose
		
		return file_exists($file) ? $file : false;
	}

	/**
	 * Create a mysql dump file using exec(mysqldump)
	 *
	 * @param string $file Path + filename to create
	 * @param array $options
	 * @return string|bool Returns the created file on success or false on error
	 * 
	 * @todo add backupStartFile/backupEndFile support
	 *
	 */
	protected function backupExec($file, array $options) {

		$cmd = $options['execCommand'];
		$cmd = str_replace(array("\n", "\t"), ' ', $cmd); 
		$cmd = str_replace('[tables]', implode(' ', $options['tables']), $cmd); 
		
		foreach($options['excludeTables'] as $table) {
			$cmd .= " --ignore-table=$table";
		}
		
		if(strpos($cmd, '[dbFile]')) {
			$cmd = str_replace('[dbFile]', $file, $cmd); 
		} else {
			$cmd .= " > $file";
		}
		
		foreach($this->databaseConfig as $key => $value) {
			$cmd = str_replace("[$key]", $value, $cmd); 
		}
		
		exec($cmd); 
		
		if(file_exists($file)) {
			if(filesize($file) > 0) return $file; 
			$this->unlink($file); 
		}
		
		return false;
	}
	
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	
	
	/**
	 * Restore/import a MySQL database dump file 
	 * 
	 * This method is designed to restore dump files created by the backup() method of this
	 * class, however it *may* also work with dump files created from other sources like 
	 * mysqldump or PhpMyAdmin.
	 * 
	 * #pw-group-actions
	 * 
	 * @param string $filename Filename to restore, optionally including path (if no path, then path set to construct is assumed)
	 * @param array $options Options to modify default behavior: 
	 * - `tables` (array): table names to restore (empty=all)
	 * - `allowDrop` (bool): allow DROP TABLE statements (default=true)
	 * - `dropAll` (bool): DROP ALL tables before restore? The allowDrop optional must also be true. (default=false)
	 * - `haltOnError` (bool): halt execution when an error occurs? (default=false)
	 * - `maxSeconds` (int): max number of seconds allowed for execution (default=1200)
	 * - `findReplace` (array): find and replace in row data. Example: ['databass' => 'database']
	 * - `findReplaceCreateTable` (array): find and replace in create table statements.   
	 *    Example: ['DEFAULT CHARSET=utf8;' => 'DEFAULT CHARSET=utf8mb4;'] 
	 * @return true on success, false on failure. Call the errors() method to retrieve errors.
	 * @throws \Exception on fatal error
	 * @see WireDatabaseBackup::backup()
	 *
	 */
	public function restore($filename, array $options = array()) {

		$filename = $this->sanitizeFilename($filename); 
		if(!file_exists($filename)) throw new \Exception("Restore file does not exist: $filename");
		$options = array_merge($this->restoreOptions, $options);
		set_time_limit($options['maxSeconds']);
		$success = false;
		
		$this->errors(true);
		$this->notes(true); 

		if($this->supportsExec($options)) {
			$this->note('method', 'exec_mysql');
			$success = $this->restoreExec($filename, $options);
			if(!$success) $this->error("Exec mysql failed, attempting PDO...");
		}

		if(!$success) {
			$this->note('method', 'pdo');
			$success = $this->restorePDO($filename, $options);
		}

		return $success;
	}

	/**
	 * Set restore options
	 * 
	 * #pw-internal 
	 * 
	 * @param array $options
	 * @return $this
	 * 
	 */
	public function setRestoreOptions(array $options) {
		$this->restoreOptions = array_merge($this->restoreOptions, $options);
		return $this;
	}

	/**
	 * Import a database SQL file using PDO
	 * 
	 * @param string $filename Filename to restore (must be SQL file exported by this class)
	 * @param array $options See $restoreOptions
	 * @return bool true on success, false on failure. Call the errors() method to retrieve errors.
	 *
	 */
	protected function restorePDO($filename, array $options = array()) {

		$fp = fopen($filename, "rb");
		if($fp === false) {
			$this->error("Unable to open: $filename"); 
			return false;
		}
		$numInserts = 0;
		$numTables = 0; 
		$numQueries = 0;
		
		if($options['allowDrop'] === true && $options['dropAll'] === true) {
			$this->dropAllTables();
		}
	
		$tables = array(); // selective tables to restore, optional
		foreach($options['tables'] as $table) $tables[$table] = $table; 
		if(!count($tables)) $tables = null;

		while(!feof($fp)) {

			$line = trim(fgets($fp));
			if(!$this->restoreUseLine($line)) continue;
			
			if(preg_match('/^(INSERT|CREATE|DROP)\s+(?:INTO|TABLE IF EXISTS|TABLE IF NOT EXISTS|TABLE)\s+`?([^\s`]+)/i', $line, $matches)) {
				$command = strtoupper($matches[1]); 
				$table = $matches[2];
			} else {
				$command = '';
				$table = '';
			}
			
			if($command === 'CREATE') { 
				if(!$options['allowDrop'] && stripos($line, 'CREATE TABLE IF NOT EXISTS') === false) {
					$line = str_ireplace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS', $line);
				}
			} else if($command === 'DROP') {
				if(!$options['allowDrop']) continue; 
				
			} else if($command === 'INSERT' && $tables) {
				if(!isset($tables[$table])) continue; // skip tables not selected for import
			}
			
			while(substr($line, -1) != ';' && !feof($fp)) {
				// get the rest of the lines in the query (if multi-line)
				$_line = trim(fgets($fp));
				if($this->restoreUseLine($_line)) $line .= $_line;
			}
		
			$replacements = $command === 'CREATE' ? $options['findReplaceCreateTable'] : $options['findReplace'];
			if(count($replacements)) foreach($replacements as $find => $replace) {
				if(strpos($line, $find) === false) continue;
				$line = str_replace($find, $replace, $line);
			}

			try {
				$this->executeQuery($line, $options); 
				if($command === 'INSERT') $numInserts++;
				if($command === 'CREATE') $numTables++;
				$numQueries++;
				
			} catch(\Exception $e) {
				$this->error($e->getMessage());
				if($options['haltOnError']) break;
			}
		}

		fclose($fp);
		
		$this->note('queries', $numQueries); 
		$this->note('inserts', $numInserts);
		$this->note('tables', $numTables); 

		if(count($this->errors) > 0) {
			$this->error(count($this->errors) . " queries generated errors ($numQueries queries and $numInserts inserts for $numTables were successful)");
			return false;
		} else {
			return $numQueries > 0;
		}
	}

	/**
	 * Import a database SQL file using exec(mysql)
	 *
	 * @param string $filename Filename to restore (must be SQL file exported by this class)
	 * @param array $options See $restoreOptions
	 * @return bool True on success, false on failure. Call the errors() method to retrieve errors.
	 *
	 */
	protected function restoreExec($filename, array $options = array()) {

		$cmd = $options['execCommand'];
		$cmd = str_replace(array("\n", "\t"), ' ', $cmd);
		$cmd = str_replace('[dbFile]', $filename, $cmd);

		foreach($this->databaseConfig as $key => $value) {
			$cmd = str_replace("[$key]", $value, $cmd);
		}

		$o = array();
		$r = 0;
		exec($cmd, $o, $r);

		if($r > 0) {
			// 0=success, 1=warning, 2=not found
			$this->error("mysql reported error code $r");
			foreach($o as $e) $this->error($e);
			return false;
		}

		return true;
	}

	/**
	 * Returns true or false if a line should be used for restore
	 *
	 * @param $line
	 * @return bool
	 *
	 */
	protected function restoreUseLine($line) {
		if(empty($line) || substr($line, 0, 2) == '--' || substr($line, 0, 1) == '#') return false;
		return true;
	}

	/**
	 * Restore from 2 SQL files while resolving table differences (think of it as array_merge for a DB restore)
	 * 
	 * The CREATE TABLE and INSERT statements in filename2 take precedence of those in filename1.
	 * INSERT statements from both will be executed, with filename2 INSERTs updating those of filename1.
	 * CREATE TABLE statements in filename1 won't be executed if they also exist in filename2.
	 * 
	 * This method assumes both files follow the SQL dump format created by this class. 
	 * 
	 * #pw-advanced
	 * 
	 * @param string $filename1 Original filename
	 * @param string $filename2 Filename that may have statements that will update/override those in filename1
	 * @param array $options
	 * @return bool True on success, false on fail. 
	 * @throws \Exception|WireException if $options['haltOnErrors'] == true. 
	 * 
	 */
	public function restoreMerge($filename1, $filename2, $options) {
		
		$options = array_merge($this->restoreOptions, $options); 
		$creates1 = $this->findCreateTables($filename1, $options); 
		$creates2 = $this->findCreateTables($filename2, $options); 
		$creates = array_merge($creates1, $creates2); // CREATE TABLE statements in filename2 override those in filename1
		$numErrors = 0;
		
		foreach($creates as $table => $create) {
			if($options['allowDrop']) {
				if(!$this->executeQuery("DROP TABLE IF EXISTS `$table`", $options)) $numErrors++;
			}
			if(!$this->executeQuery($create, $options)) $numErrors++;
		}
		
		$inserts = $this->findInserts($filename1); 
		foreach($inserts as /*$table =>*/ $tableInserts) {
			foreach($tableInserts as $insert) {
				if(!$this->executeQuery($insert, $options)) $numErrors++;
			}
		}
	
		// Convert line 1 to line 2:
		// 1. INSERT INTO `field_process` (pages_id, data) VALUES('6', '17'); 
		// 2. INSERT INTO `field_process` (pages_id, data) VALUES('6', '17') ON DUPLICATE KEY UPDATE pages_id=VALUES(pages_id), data=VALUES(data);
		
		$inserts = $this->findInserts($filename2);
		foreach($inserts as $table => $tableInserts) {
			foreach($tableInserts as $insert) {
				// check if table existed in both dump files, and has no duplicate update statement
				$regex = '/\s+ON\s+DUPLICATE\s+KEY\s+UPDATE\s+[^\'";]+;$/i';
				if(isset($creates1[$table]) && !preg_match($regex, $insert)) {
					// line doesn't already contain an ON DUPLICATE section, so we need to add it
					$pos1 = strpos($insert, '(') + 1; 
					$pos2 = strpos($insert, ')') - $pos1;
					$fields = substr($insert, $pos1, $pos2);
					$insert = rtrim($insert, '; ') . " ON DUPLICATE KEY UPDATE ";
					foreach(explode(',', $fields) as $name) {
						$name = trim($name); 
						$insert .= "$name=VALUES($name), ";
					}
					$insert = rtrim($insert, ", ") . ";";
				}
				if(!$this->executeQuery($insert, $options)) $numErrors++;
			}
		}
		
		return $numErrors === 0;
	}

	/**
	 * Drop all tables from database
	 * 
	 * @return int Quantity of tables dropped
	 * @throws \Exception
	 * @since 3.0.130
	 * 
	 */
	public function dropAllTables() {
		
		$database = $this->getDatabase();
		$tables = $this->getAllTables(false, false);
		$qty = 0;
		
		$database->exec("SET FOREIGN_KEY_CHECKS=0");
		
		foreach($tables as $table) {
			if($database->exec("DROP TABLE IF EXISTS `$table`")) $qty++;
		}
		
		$database->exec("SET FOREIGN_KEY_CHECKS=1");
		
		return $qty;
	}
		

	////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	
	/**
	 * Returns array of all create table statements, indexed by table name
	 *
	 * @param string $filename to extract all CREATE TABLE statements from
	 * @param string $regex Regex (PCRE) to match for statement to be returned, must stuff table name into first match
	 * @param bool $multi Whether there can be multiple matches per table
	 * @return array of statements, indexed by table name. If $multi is true, it will be array of arrays.
	 * @throws \Exception if unable to open specified file
	 *
	 */
	protected function findStatements($filename, $regex, $multi = true) {
		$filename = $this->sanitizeFilename($filename); 
		$fp = fopen($filename, 'r');
		if(!$fp) throw new \Exception("Unable to open: $filename"); 
		$statements = array();
		while(!feof($fp)) {
			$line = trim(fgets($fp));
			if(!preg_match($regex, $line, $matches)) continue;
			if(empty($matches[1])) continue; 
			$table = $matches[1];
			while(substr($line, -1) != ';' && !feof($fp)) $line .= " " . rtrim(fgets($fp));
			if($multi) {
				if(!isset($statements[$table])) $statements[$table] = array();
				$statements[$table][] = $line;
			} else {
				$statements[$table] = $line;
			}
		}
		fclose($fp);
		return $statements;
	}

	/**
	 * Returns array of all create table statements, indexed by table name
	 * 
	 * #pw-internal
	 * 
	 * @param string $filename to extract all CREATE TABLE statements from
	 * @param array $options
	 * @return array of CREATE TABLE statements, associative: indexed by table name
	 * @throws \Exception if unable to open specified file
	 *
	 */
	public function findCreateTables($filename, array $options) {
		$regex = '/^CREATE\s+TABLE\s+`?([^`\s]+)/i';
		$statements = $this->findStatements($filename, $regex, false);
		if(!empty($options['findReplaceCreateTable'])) {
			foreach($options['findReplaceCreateTable'] as $find => $replace) {
				foreach($statements as $key => $line) {
					if(strpos($line, $find) === false) continue;
					$line = str_replace($find, $replace, $line);
					$statements[$key] = $line;
				}
			}
		}
		return $statements;
	}

	/**
	 * Returns array of all INSERT statements in given filename, indexed by table name
	 * 
	 * #pw-internal
	 *
	 * @param string $filename to extract all CREATE TABLE statements from
	 * @return array of arrays of INSERT statements. Base array is associative indexed by table name. 
	 * 	Inside arrays are numerically indexed by order of appearance. 
	 *
	 */
	public function findInserts($filename) {
		$regex = '/^INSERT\s+INTO\s+`?([^`\s]+)/i';
		return $this->findStatements($filename, $regex, true); 
	}

	/**
	 * Execute an SQL query, either a string or PDOStatement
	 * 
	 * @param string|\PDOStatement $query
	 * @param bool|array $options May be boolean (for haltOnError), or array containing the property (i.e. $options array)
	 *  - `haltOnError` (bool): Halt execution when error occurs? (default=false)
	 * @return bool Query result
	 * @throws \Exception if haltOnError, otherwise it populates $this->errors
	 * 
	 */
	protected function executeQuery($query, $options = array()) {
		$defaults = array(
			'haltOnError' => false
		);
		if(is_bool($options)) {
			$defaults['haltOnError'] = $options;
			$options = array();
		}
		$options = array_merge($defaults, $options);
		$result = false;
		try {
			if(is_string($query)) {
				$result = $this->getDatabase()->exec($query); 
			} else if($query instanceof \PDOStatement) {
				$result = $query->execute();
			}
		} catch(\Exception $e) {
			if(empty($options['haltOnError'])) {
				$this->error($e->getMessage());
			} else {
				throw $e;
			}
		}
		return $result === false ? false : true;
	}

	/**
	 * For path: Normalizes slashes and ensures it ends with a slash
	 *
	 * @param $path
	 * @return string
	 *
	 */
	protected function sanitizePath($path) {
		if(DIRECTORY_SEPARATOR != '/') $path = str_replace(DIRECTORY_SEPARATOR, '/', $path); 
		$path = rtrim($path, '/') . '/'; // ensure it ends with trailing slash
		return $path; 
	}

	/**
	 * For filename: Normalizes slashes and ensures it starts with a path
	 * 
	 * @param $filename
	 * @return string
	 * @throws \Exception if path has not yet been set
	 * 
	 */
	protected function sanitizeFilename($filename) {
		if(DIRECTORY_SEPARATOR != '/') $filename = str_replace(DIRECTORY_SEPARATOR, '/', $filename);
		if(strpos($filename, '/') === false) {
			$filename = $this->path . $filename;
		}
		if(strpos($filename, '/') === false) {
			$path = $this->getPath();
			if(!strlen($path)) throw new \Exception("Please supply full path to file, or call setPath('/backup/files/path/') first");
			$filename = $path . $filename; 
		}
		return $filename; 
	}
	
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////

	/**
	 * Determine if exec is available for the given command
	 * 
	 * Note that WireDatabaseBackup does not currently use exec() mode so this is here for future use. 
	 * 
	 * @param array $options
	 * @return bool
	 * @throws \Exception on unknown exec type
	 * 
	 */
	protected function supportsExec(array $options = array()) {

		if(!$options['exec']) return false;
		
		if(empty($this->databaseConfig['dbUser'])) return false; // no db config options provided
		
		if(preg_match('{^(?:\[dbPath\])?([_a-zA-Z0-9]+)\s}', $options['execCommand'], $matches)) {
			$type = $matches[1]; 
		} else {
			throw new \Exception("Unable to determine command for exec"); 
		}

		if($type == 'mysqldump') {
			// these options are not supported by mysqldump via exec
			if(	!empty($options['excludeCreateTables']) || 
				!empty($options['excludeExportTables']) ||
				!empty($options['findReplace']) ||
				!empty($options['findReplaceCreateTable']) ||
				!empty($options['allowUpdateTables']) || 
				!empty($options['allowUpdate'])) {
				return false;
			}
			
		} else if($type == 'mysql') {
			// these options are not supported by mysql via exec
			if(	!empty($options['tables']) ||
				!empty($options['allowDrop']) ||
				!empty($options['findReplace']) ||
				!empty($options['findReplaceCreateTable'])) {
				return false;
			}
			
		} else {
			throw new \Exception("Unrecognized exec command: $type"); 
		}
		
		// first check if exec is available (http://stackoverflow.com/questions/3938120/check-if-exec-is-disabled)
		if(ini_get('safe_mode')) return false; 
		$d = ini_get('disable_functions');
		$s = ini_get('suhosin.executor.func.blacklist');
		if("$d$s") {
			$a = preg_split('/,\s*/', "$d,$s");
			if(in_array('exec', $a)) return false;
		}
	
		// now check if mysqldump is available
		$o = array();
		$r = 0; 
		$path = $this->databaseConfig['dbPath'];
		exec("{$path}$type --version", $o, $r); 
		if(!$r && count($o) && stripos($o[0], $type) !== false && stripos($o[0], 'Ver') !== false) {
			// i.e. mysqldump  Ver 10.13 Distrib 5.5.34, for osx10.6 (i386)
			return true; 
		}
		
		return false;
	}
	
}
