<?php

/**
 * Walks every table in db that then walks every row and column replacing searches with replaces
 * large tables are split into 50k row blocks to save on memory.
 *
 * Standard: PSR-2
 * @link http://www.php-fig.org/psr/psr-2 Full Documentation
 *
 * @package SC\DUPX\UpdateEngine
 *
 */
class DUPX_UpdateEngine
{

	/**
	 *  Used to report on all log errors into the installer-txt.log
	 *
	 *  @param string $report   The report error array of all error types
	 *
	 *  @return string Writes the results of the update engine tables to the log
	 */
	public static function logErrors($report)
	{
		if (!empty($report['errsql'])) {
			DUPX_Log::info("--------------------------------------");
			DUPX_Log::info("DATA-REPLACE ERRORS (MySQL)");
			foreach ($report['errsql'] as $error) {
				DUPX_Log::info($error);
			}
			DUPX_Log::info("");
		}
		if (!empty($report['errser'])) {
			DUPX_Log::info("--------------------------------------");
			DUPX_Log::info("DATA-REPLACE ERRORS (Serialization):");
			foreach ($report['errser'] as $error) {
				DUPX_Log::info($error);
			}
			DUPX_Log::info("");
		}
		if (!empty($report['errkey'])) {
			DUPX_Log::info("--------------------------------------");
			DUPX_Log::info("DATA-REPLACE ERRORS (Key):");
			DUPX_Log::info('Use SQL: SELECT @row := @row + 1 as row, t.* FROM some_table t, (SELECT @row := 0) r');
			foreach ($report['errkey'] as $error) {
				DUPX_Log::info($error);
			}
		}
	}

	/**
	 *  Used to report on all log stats into the installer-txt.log
	 *
	 *  @param string $report   The report stats array of all error types
	 *
	 *  @return string Writes the results of the update engine tables to the log
	 */
	public static function logStats($report)
	{
		if (!empty($report) && is_array($report)) {
			$stats	 = "--------------------------------------\n";
			$srchnum = 0;
			foreach ($GLOBALS['REPLACE_LIST'] as $item) {
				$srchnum++;
				$stats .= sprintf("Search{$srchnum}:\t'%s' \nChange{$srchnum}:\t'%s' \n", $item['search'], $item['replace']);
			}
			$stats .= sprintf("SCANNED:\tTables:%d \t|\t Rows:%d \t|\t Cells:%d \n", $report['scan_tables'], $report['scan_rows'], $report['scan_cells']);
			$stats .= sprintf("UPDATED:\tTables:%d \t|\t Rows:%d \t|\t Cells:%d \n", $report['updt_tables'], $report['updt_rows'], $report['updt_cells']);
			$stats .= sprintf("ERRORS:\t\t%d \nRUNTIME:\t%f sec", $report['err_all'], $report['time']);
			DUPX_Log::info($stats);
		}
	}

	/**
	 * Returns only the text type columns of a table ignoring all numeric types
	 *
	 * @param obj    $dbh       A valid database link handle
	 * @param string $table     A valid table name
	 *
	 * @return array All the column names of a table
	 */
	public static function getTextColumns($dbh, $table)
	{
		$type_where = "type NOT LIKE 'tinyint%' AND ";
		$type_where .= "type NOT LIKE 'smallint%' AND ";
		$type_where .= "type NOT LIKE 'mediumint%' AND ";
		$type_where .= "type NOT LIKE 'int%' AND ";
		$type_where .= "type NOT LIKE 'bigint%' AND ";
		$type_where .= "type NOT LIKE 'float%' AND ";
		$type_where .= "type NOT LIKE 'double%' AND ";
		$type_where .= "type NOT LIKE 'decimal%' AND ";
		$type_where .= "type NOT LIKE 'numeric%' AND ";
		$type_where .= "type NOT LIKE 'date%' AND ";
		$type_where .= "type NOT LIKE 'time%' AND ";
		$type_where .= "type NOT LIKE 'year%' ";

		$result = mysqli_query($dbh, "SHOW COLUMNS FROM `{$table}` WHERE {$type_where}");
		if (!$result) {
			return null;
		}
		$fields = array();
		if (mysqli_num_rows($result) > 0) {
			while ($row = mysqli_fetch_assoc($result)) {
				$fields[] = $row['Field'];
			}
		}

		//Return Primary which is needed for index lookup
		//$result = mysqli_query($dbh, "SHOW INDEX FROM `{$table}` WHERE KEY_NAME LIKE '%PRIMARY%'"); 1.1.15 updated
		$result = mysqli_query($dbh, "SHOW INDEX FROM `{$table}`");
		if (mysqli_num_rows($result) > 0) {
			while ($row = mysqli_fetch_assoc($result)) {
				$fields[] = $row['Column_name'];
			}
		}

		return (count($fields) > 0) ? $fields : null;
	}

	/**
	 * Begins the processing for replace logic
	 *
	 * @param mysql  $dbh			The db connection object
	 * @param array  $list			Key value pair of 'search' and 'replace' arrays
	 * @param array  $tables		The tables we want to look at
	 * @param array  $fullsearch    Search every column regardless of its data type
	 *
	 * @return array Collection of information gathered during the run.
	 */
	public static function load($dbh, $list = array(), $tables = array(), $fullsearch = false)
	{
		$report = array(
			'scan_tables' => 0,
			'scan_rows' => 0,
			'scan_cells' => 0,
			'updt_tables' => 0,
			'updt_rows' => 0,
			'updt_cells' => 0,
			'errsql' => array(),
			'errser' => array(),
			'errkey' => array(),
			'errsql_sum' => 0,
			'errser_sum' => 0,
			'errkey_sum' => 0,
			'time' => '',
			'err_all' => 0
		);

        // Let's use anonymous function after PHP5.3.0 - is faster than create_function()
        // create_function() is removed from PHP 7.2
        if(DUPX_U::isVersion('5.3.0')) {
            // Use "try catch" to avoid PHP notice or error below PHP5.3.0
            try {
                $walk_function = function () use (&$str) {
                    $str = "`$str`";
                };
            }
			catch (Exception $exc) {}
        } else {
            $walk_function = create_function('&$str', '$str = "`$str`";');
        }
        
		$profile_start = DUPX_U::getMicrotime();
		if (is_array($tables) && !empty($tables)) {

			foreach ($tables as $table) {
				$report['scan_tables'] ++;
				$columns = array();

				// Get a list of columns in this table
				$fields	 = mysqli_query($dbh, 'DESCRIBE '.$table);
				while ($column	 = mysqli_fetch_array($fields)) {
					$columns[$column['Field']] = $column['Key'] == 'PRI' ? true : false;
				}

				// Count the number of rows we have in the table if large we'll split into blocks
				$row_count	 = mysqli_query($dbh, "SELECT COUNT(*) FROM `{$table}`");
				$rows_result = mysqli_fetch_array($row_count);
				@mysqli_free_result($row_count);
				$row_count	 = $rows_result[0];
				if ($row_count == 0) {
					DUPX_Log::info("{$table}^ ({$row_count})");
					continue;
				}

				$page_size	 = 25000;
				$offset		 = ($page_size + 1);
				$pages		 = ceil($row_count / $page_size);

				// Grab the columns of the table.  Only grab text based columns because
				// they are the only data types that should allow any type of search/replace logic
				$colList = '*';
				$colMsg	 = '*';
				if (!$fullsearch) {
					$colList = self::getTextColumns($dbh, $table);
					if ($colList != null && is_array($colList)) {
						array_walk($colList, $walk_function);
						$colList = implode(',', $colList);
					}
					$colMsg = (empty($colList)) ? '*' : '~';
				}

				if (empty($colList)) {
					DUPX_Log::info("{$table}^ ({$row_count})");
					continue;
				} else {
					DUPX_Log::info("{$table}{$colMsg} ({$row_count})");
				}

				//Paged Records
				for ($page = 0; $page < $pages; $page++) {
					$current_row = 0;
					$start		 = $page * $page_size;
					$end		 = $start + $page_size;
					$sql		 = sprintf("SELECT {$colList} FROM `%s` LIMIT %d, %d", $table, $start, $offset);
					$data		 = mysqli_query($dbh, $sql);

					if (!$data) $report['errsql'][] = mysqli_error($dbh);

					$scan_count = ($row_count < $end) ? $row_count : $end;
					DUPX_Log::info("\tScan => {$start} of {$scan_count}", 2);

					//Loops every row
					while ($row = mysqli_fetch_array($data)) {
						$report['scan_rows'] ++;
						$current_row++;
						$upd_col	 = array();
						$upd_sql	 = array();
						$where_sql	 = array();
						$upd		 = false;
						$serial_err	 = 0;
                        $is_unkeyed = !in_array(true,$columns);

						//Loops every cell
						foreach ($columns as $column => $primary_key) {
							$report['scan_cells'] ++;
							$edited_data		= $data_to_fix = $row[$column];
							$base64converted	= false;
							$txt_found			= false;

                            //Unkeyed table code
                            //Added this here to add all columns to $where_sql
                            //The if statement with $txt_found would skip additional columns
                            if($is_unkeyed && ! empty($data_to_fix)) {
                                $where_sql[] = $column.' = "'.mysqli_real_escape_string($dbh, $data_to_fix).'"';
                            }

							//Only replacing string values
							if (!empty($row[$column]) && !is_numeric($row[$column]) && $primary_key != 1) {
								//Base 64 detection
								if (base64_decode($row[$column], true)) {
									$decoded = base64_decode($row[$column], true);
									if (self::isSerialized($decoded)) {
										$edited_data	 = $decoded;
										$base64converted	 = true;
									}
								}

								//Skip table cell if match not found
								foreach ($list as $item) {
									if (strpos($edited_data, $item['search']) !== false) {
										$txt_found = true;
										break;
									}
								}
								if (!$txt_found) {
									continue;
								}

								//Replace logic - level 1: simple check on any string or serialized strings
								foreach ($list as $item) {
									$edited_data = self::recursiveUnserializeReplace($item['search'], $item['replace'], $edited_data);
								}

								//Replace logic - level 2: repair serialized strings that have become broken
								$serial_check = self::fixSerialString($edited_data);
								if ($serial_check['fixed']) {
									$edited_data = $serial_check['data'];
								} elseif ($serial_check['tried'] && !$serial_check['fixed']) {
									$serial_err++;
								}
							}

							//Change was made
							if ($edited_data != $data_to_fix || $serial_err > 0) {
								$report['updt_cells'] ++;
								//Base 64 encode
								if ($base64converted) {
									$edited_data = base64_encode($edited_data);
								}
								$upd_col[]	 = $column;
								$upd_sql[]	 = $column.' = "'.mysqli_real_escape_string($dbh, $edited_data).'"';
								$upd		 = true;
							}

							if ($primary_key) {
								$where_sql[] = $column.' = "'.mysqli_real_escape_string($dbh, $data_to_fix).'"';
							}
						}

						//PERFORM ROW UPDATE
						if ($upd && !empty($where_sql)) {
							$sql	= "UPDATE `{$table}` SET ".implode(', ', $upd_sql).' WHERE '.implode(' AND ', array_filter($where_sql));
							$result	= mysqli_query($dbh, $sql);
							if ($result) {
								if ($serial_err > 0) {
									$report['errser'][] = "SELECT " . implode(', ', $upd_col) . " FROM `{$table}`  WHERE " . implode(' AND ', array_filter($where_sql)) . ';';
								}
								$report['updt_rows']++;
							} else  {
								$report['errsql'][]	 = ($GLOBALS["LOGGING"] == 1)
									? 'DB ERROR: ' . mysqli_error($dbh)
									: 'DB ERROR: ' . mysqli_error($dbh) . "\nSQL: [{$sql}]\n";
							}

							//DEBUG ONLY:
							DUPX_Log::info("\t{$sql}\n", 3);

						} elseif ($upd) {
							$report['errkey'][] = sprintf("Row [%s] on Table [%s] requires a manual update.", $current_row, $table);
						}
					}
					//DUPX_U::fcgiFlush();
					@mysqli_free_result($data);
				}

				if ($upd) {
					$report['updt_tables'] ++;
				}
			}
		}
		$profile_end			 = DUPX_U::getMicrotime();
		$report['time']			 = DUPX_U::elapsedTime($profile_end, $profile_start);
		$report['errsql_sum']	 = empty($report['errsql']) ? 0 : count($report['errsql']);
		$report['errser_sum']	 = empty($report['errser']) ? 0 : count($report['errser']);
		$report['errkey_sum']	 = empty($report['errkey']) ? 0 : count($report['errkey']);
		$report['err_all']		 = $report['errsql_sum'] + $report['errser_sum'] + $report['errkey_sum'];
		return $report;
	}

	/**
	 * Take a serialised array and unserialise it replacing elements and
	 * unserialising any subordinate arrays and performing the replace.
	 *
	 * @param string $from       String we're looking to replace.
	 * @param string $to         What we want it to be replaced with
	 * @param array  $data       Used to pass any subordinate arrays back to in.
	 * @param bool   $serialised Does the array passed via $data need serialising.
	 *
	 * @return array	The original array with all elements replaced as needed. 
	 */
	public static function recursiveUnserializeReplace($from = '', $to = '', $data = '', $serialised = false)
	{
		// some unseriliased data cannot be re-serialised eg. SimpleXMLElements
		try {
			if (is_string($data) && ($unserialized = @unserialize($data)) !== false) {
				$data = self::recursiveUnserializeReplace($from, $to, $unserialized, true);
			} elseif (is_array($data)) {
				$_tmp = array();
				foreach ($data as $key => $value) {
					$_tmp[$key] = self::recursiveUnserializeReplace($from, $to, $value, false);
				}
				$data = $_tmp;
				unset($_tmp);

				/* CJL
				  Check for an update to the key of an array e.g.   [http://localhost/projects/wpplugins/] => 1.41
				  This could have unintended consequences would need to enable with full-search needs more testing
				  if (array_key_exists($from, $data))
				  {
				  $data[$to] = $data[$from];
				  unset($data[$from]);
				  } */
			} elseif (is_object($data)) {
				/* RSR Old logic that didn't work with Beaver Builder - they didn't want to create a brand new
				  object instead reused the existing one...
				  $dataClass = get_class($data);
				  $_tmp = new $dataClass();
				  foreach ($data as $key => $value) {
				  $_tmp->$key = self::recursiveUnserializeReplace($from, $to, $value, false);
				  }
				  $data = $_tmp;
				  unset($_tmp); */

				// RSR NEW LOGIC
				$_tmp	 = $data;
				$props	 = get_object_vars($data);
				foreach ($props as $key => $value) {
					$_tmp->$key = self::recursiveUnserializeReplace($from, $to, $value, false);
				}
				$data = $_tmp;
				unset($_tmp);
			} else {
				if (is_string($data)) {
					$data = str_replace($from, $to, $data);
				}
			}

			if ($serialised) return serialize($data);
		} catch (Exception $error) {
			DUPX_Log::info("\nRECURSIVE UNSERIALIZE ERROR: With string\n".$error, 2);
		}
		return $data;
	}

	/**
	 * Test if a string in properly serialized
	 *
	 * @param string $data  Any string type
	 *
	 * @return bool Is the string a serialized string
	 */
	public static function isSerialized($data)
	{
		$test = @unserialize(($data));
		return ($test !== false || $test === 'b:0;') ? true : false;
	}

	/**
	 *  Fixes the string length of a string object that has been serialized but the length is broken
	 *
	 *  @param string $data	The string ojbect to recalculate the size on.
	 *
	 *  @return string  A serialized string that fixes and string length types
	 */
	public static function fixSerialString($data)
	{
		$result = array('data' => $data, 'fixed' => false, 'tried' => false);
		if (preg_match("/s:[0-9]+:/", $data)) {
			if (!self::isSerialized($data)) {
				$regex			 = '!(?<=^|;)s:(\d+)(?=:"(.*?)";(?:}|a:|s:|b:|d:|i:|o:|N;))!s';
				$serial_string	 = preg_match('/^s:[0-9]+:"(.*$)/s', trim($data), $matches);
				//Nested serial string
				if ($serial_string) {
					$inner				 = preg_replace_callback($regex, 'DUPX_UpdateEngine::fixStringCallback', rtrim($matches[1], '";'));
					$serialized_fixed	 = 's:'.strlen($inner).':"'.$inner.'";';
				} else {
					$serialized_fixed = preg_replace_callback($regex, 'DUPX_UpdateEngine::fixStringCallback', $data);
				}

				if (self::isSerialized($serialized_fixed)) {
					$result['data']	 = $serialized_fixed;
					$result['fixed'] = true;
				}
				$result['tried'] = true;
			}
		}
		return $result;
	}

	/**
	 *  The call back method call from via fixSerialString
	 */
	private static function fixStringCallback($matches)
	{
		return 's:'.strlen(($matches[2]));
	}
}
?>