<?php

/**
 * Author: Mikołaj `iClyde` Chodorowski
 * Contact: kontakt@iclyde.pl
 * Package: Backup Migration – WP Plugin
 */

// Namespace
namespace BMI\Plugin\Database;

// Use
use BMI\Plugin\BMI_Logger AS Logger;
use BMI\Plugin\Backup_Migration_Plugin as BMP;
use BMI\Plugin\Progress\BMI_ZipProgress AS Progress;

// Exit on direct access
if (!defined('ABSPATH')) exit;

global $bmi_last_seek, $bmi_last_file;
// register_shutdown_function(function () {
//
//   echo $GLOBALS['bmi_last_file'] . ' - ' . $GLOBALS['bmi_last_seek'];
//
// });

/**
 * Database importing
 * Main Class, requires $wpdb
 */
class BMI_Database_Importer {

  /**
   * Private local variables
   */
  private $files = [];

  /**
   * __construct - Initialization and logger resolver
   *
   * @return self
   */
  function __construct($storage, $total_queries, $old_abspath, $old_domain, $new_domain, &$logger, $isCLI, $conversionStats) {

    /**
     * WP Global Database variable
     */
    global $wpdb;
    $this->wpdb = &$wpdb;

    /**
     * Set isCLI variable
     */
    $this->isCLI = $isCLI;

    /**
     * Logger of BMI core
     */
    $this->logger = &$logger;

    /**
     * Storage directory
     */
    // $this->storage = trailingslashit(__DIR__) . 'data';
    $this->storage = $storage;
    $this->total_queries = intval($total_queries);
    // $this->total_queries = $this->conversionStats['total_queries'];

    // Conversion stats
    $this->conversionStats = $conversionStats;

    /**
     * Domain(s) configuration
     * ? - All cases that domain can be represented in the database
     */
    $this->source_domain = untrailingslashit($old_domain);
    $this->new_domain = untrailingslashit($new_domain); // untrailingslashit(home_url());
    $this->json_source_domain = trim(json_encode($this->source_domain), '"');
    $this->json_new_domain = trim(json_encode($this->new_domain), '"');
    $this->no_protocol_source_domain = ltrim(ltrim($this->source_domain, "https://"), "http://");
    $this->no_protocol_new_domain = ltrim(ltrim($this->new_domain, "https://"), "http://");
    $this->json_no_protocol_source_domain = ltrim(ltrim($this->json_source_domain, "https:\/\/"), "http:\/\/");
    $this->json_no_protocol_new_domain = ltrim(ltrim($this->json_new_domain, "https:\/\/"), "http:\/\/");

    $this->source_abs = untrailingslashit($old_abspath);
    $this->new_abs = untrailingslashit(ABSPATH);
    $this->json_source_abs = trim(json_encode($this->source_abs), '"');
    $this->json_new_abs = trim(json_encode($this->new_abs), '"');

    $this->xi = 0;
    $this->current_file = '';
    $this->init_start = microtime(true);
    $this->last_finish = $this->init_start;
    $this->table_names_alter = [];

    @ini_set('memory_limit', '-1');
    if ((class_exists('mysqli') || class_exists('\mysqli')) && false) {
      $this->owndb = true;
      $this->db = new \mysqli(DB_HOST, DB_USER, DB_PASSWORD, DB_NAME);
      if ($this->db->connect_error) $this->owndb = false;
      else $this->db->query('SET foreign_key_checks = 0;');
    } else {
      $this->owndb = false;
    }

    set_error_handler(function($errno, $errstr, $errfile, $errline) {
      
      if (BMI_DEBUG) {
          error_log('BMI DEBUG ENABLED, HERE IS THE COMPLETE REPORT (ERROR HANDLER #4):');
          error_log(print_r($errno, true));
          error_log(print_r($errstr, true));
          error_log(print_r($errfile, true));
          error_log(print_r($errline, true));
        }
      
      // The only notice we could get here is Instance does not exist, ignore it.
      if ($errno === '8' || $errno === 8 || $errno === E_NOTICE) {
        if ($errfile == __FILE__) {
          throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
          return;
        }
      }

      // error_log($errno .' '. $errstr .' '. $errfile .':'. $errline);
      // throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);

    });

  }

  /**
   * showFirstLogs - Shows logs for initialization
   *
   * @return @void
   */
  public function showFirstLogs() {

    $this->logger->log("Initializing database import V2 engine...", 'INFO');
    $this->logger->log("Initialized successfully, memory usage: " . number_format(memory_get_usage() / 1024 / 1024, 2) . " MB", 'SUCCESS');

  }

  /**
   * showFirstLogs - Shows logs after finish
   *
   * @return @void
   */
  public function showEndLogs() {

    $from_start = number_format(microtime(true) - $this->init_start, 4);
    $this->logger->log("Total time to insert " . $this->xi . " queries took " . $from_start . " seconds.", 'INFO');

  }

  /**
   * import - Import initializer
   *
   * @return {bool} true on success false on fail
   */
  public function import() {

    $this->logger->log("Loading file list...", 'INFO');
    $this->load_file_list();
    $this->logger->log("File list loaded, memory usage: " . number_format(memory_get_usage() / 1024 / 1024, 2) . " MB", 'SUCCESS');

    $this->logger->log("Restoring tables using temporary name...", 'INFO');
    $this->restore_tables();

    // Mark those as finished
    $GLOBALS['bmi_last_file'] = false;
    $GLOBALS['bmi_last_seek'] = false;

    $this->showEndLogs();

  }

  /**
   * restore_by_file - Allows to restore by one file
   *
   * @param  {string} path to sql file
   * @param  {int} last seek position of this file
   * @return {int/bool} last seek number or bool if completed
   */
  public function restore_by_file($path, $last_seek = 0) {

    if (!file_exists($path)) {
      if (file_exists($path)) @unlink($path);
      $this->logger->log("Database file does not exist anymore (" . basename($path) . ")? – Omitting.", 'INFO');
      return true;
    }

    $file = new \SplFileObject($path);
    $file->seek($file->getSize());
    $total_lines = $file->key() + 1;
    $this->current_file = $path;

    $last_real_table_name = null;
    $custom_var_started = false;
    $values_started = false;
    $query_ended = false;
    $query = '';

    $query_exist = false;

    $last_seek_start = $last_seek;

    for ($i = $last_seek; $i < $total_lines; ++$i) {

      // Set the pointer
      $GLOBALS['bmi_last_seek'] = $i;
      $file->seek($i);

      // Get the line
      $line = trim($file->current());

      // Find our declaractions
      if ($line == ltrim("\/* QUERY START */", '\\')) $query_ended = false;
      elseif ($line == ltrim("\/* CUSTOM VARS START */", '\\')) $custom_var_started = true;
      elseif ($line == ltrim("\/* CUSTOM VARS END */", '\\')) $custom_var_started = false;
      elseif ($line == ltrim("\/* VALUES START */", '\\')) $values_started = true;
      elseif ($line == ltrim("\/* VALUES END */", '\\')) $values_started = false;
      elseif ($line == ltrim("\/* QUERY END */", '\\')) $query_ended = true;
      else {

        if ($custom_var_started === true) {

          $line = rtrim($line, "\n");
          $the_variable = null;
          preg_match("/`(.*)`/i", $line, $the_variable);
          $the_variable = $the_variable[1];

          if (strpos($line, 'REAL_TABLE_NAME') !== false) {
            $this->table_names_alter[$the_variable] = '';
            $last_real_table_name = $the_variable;
          } else if (strpos($line, 'PRE_TABLE_NAME') !== false) {
            $this->table_names_alter[$last_real_table_name] = $the_variable;
          }

        }

        if (strlen($line) > 0 || $values_started === true) {

          $line = rtrim($line, "\n");

          if ($values_started === true && $query_ended != true) {

            if ($this->source_domain != $this->new_domain) {

              $this->line_replace($line);

            }

          }

          $query .= $line;

        }

        if ($query_ended === true) {

          $this->run_query($query);
          $values_started = false;
          $query_ended = false;
          $query_exist = true;
          $query = '';
          $last_seek = $i;

          if (($last_seek_start + 100) < $i) {
            break;
          }

        }

      }

    }

    if ($query_exist === false) {
      if (file_exists($path)) {
        gc_collect_cycles();
        $file = null;
        @unlink($path);
      }
      return true;
    } else {
      $file = null;
      return $last_seek;
    }

  }

  /**
   * get_sql_files - Returns all SQL files included in the backup
   *
   * @return {array} List of SQL files
   */
  public function get_sql_files($first_db = true) {

    if ($first_db == true) {
      $this->logger->log("Loading file list...", 'INFO');
    }

    $this->load_file_list();

    if ($first_db == true) {
      $this->logger->log("File list loaded, memory usage: " . number_format(memory_get_usage() / 1024 / 1024, 2) . " MB", 'SUCCESS');
    }

    return $this->files;

  }

  /**
   * restore_tables - Line by line reader and query builder
   * It uses path provided in __construct to get the files
   * I.e. it will try to load all .sql files located in the folder
   * Important, those failes have to be generated with better-backup module
   *
   * @return {void}
   */
  private function restore_tables() {

    foreach ($this->files as $file_index => $path) {

      $GLOBALS['bmi_last_file'] = $path;
      $this->current_file = $path;

      $import = 0;

      do {

        $import = $this->restore_by_file($path, $import);

      } while ($import !== true);

    }

    $this->alter_names();
    $this->queries_ended();

  }

  /**
   * queries_ended - Runs when all queries imported
   *
   * @return @void
   */
  public function queries_ended() {

    if ($this->owndb == true) {

      $this->db->close();

    } else {

      $foreign_keys = "SET foreign_key_checks = 1;";
      $this->run_query($foreign_keys);

    }

  }

  /**
   * alter_names - Replaces temporary tables with final name
   *
   * @return {void}
   */
  public function alter_names() {

    $this->logger->log(__('Switching tables to new ones and removing old ones.', 'backup-backup'), 'STEP');

    foreach ($this->table_names_alter as $final_name => $tmp_name) {

      $sql = "DROP TABLE IF EXISTS `$final_name`;";
      $this->run_query($sql, true);

      $sql = "ALTER TABLE `$tmp_name` RENAME TO `$final_name`;";
      $this->run_query($sql, true);

    }

    $this->logger->log(__('Tables replaced successfully.', 'backup-backup'), 'SUCCESS');

  }

  /**
   * replace_domain_string - Replaces the string recursively
   * It will find all possibilites to replace even the same string multiple times
   *
   * @param  {string} &$string = '' String pointer declarated somewhere else to save memory
   * @return {void} - It modifies the root variable
   */
  private function replace_domain_string(&$string = '', &$replaced = false) {

    $replacement = $this->is_replacable($string);
    if ($replacement !== false) {

      $string = str_replace($replacement[0], $replacement[1], $string);
      $replaced = true;

      if ($this->is_replacable($string) !== false) {
        $replaced = false;
        $this->replace_domain_string($string, $replaced);
      }

    }

  }

  /**
   * iterate_maybe_serial_string - It checks if the serialized thing was nested
   * And makes the serialization for each once again to get the right object
   *
   * @param  {string} &$string String that may be serializable
   * @return {void} It modifies pointer
   */
  private function iterate_maybe_serial_string(&$string) {

    // $serial = $this->is_valid_serialized($string, false);
    if (is_serialized($string)) {

      // $this->iterate_maybe_serial_string($string);
      $string = @unserialize($string, ['allowed_classes' => false]);
      $this->iterate_object($string);
      $string = @serialize($string);

    } else {

      $this->iterate_object($string);

    }

  }

  /**
   * is_replacable - Checks if the string is repecable, if it contains the old domain
   *
   * @param  {string} &$string String that can include old domain name
   * @return {void} It modifies pointer
   */
  private function is_replacable(&$string) {

    if (strpos($string, $this->source_domain) !== false) {
      return [$this->source_domain, $this->new_domain];
    }

    if (strpos($string, $this->json_source_domain) !== false) {
      return [$this->json_source_domain, $this->json_new_domain];
    }

    if (strpos($string, $this->no_protocol_source_domain) !== false) {
      return [$this->no_protocol_source_domain, $this->no_protocol_new_domain];
    }

    if (strpos($string, $this->json_no_protocol_source_domain) !== false) {
      return [$this->json_no_protocol_source_domain, $this->json_no_protocol_new_domain];
    }

    if (strpos($string, $this->source_abs) !== false) {
      return [$this->source_abs, $this->new_abs];
    }

    if (strpos($string, $this->json_source_abs) !== false) {
      return [$this->json_source_abs, $this->json_new_abs];
    }

    return false;

  }

  /**
   * check_key - Checks object key
   * It allows to modify object keys in case the domain was used there
   * E.g. some redirect plugins may store it in the database this way
   *
   * @param  {string} $key Object key or normal string
   * @return {string/bool} String if it's replacable bool false if nothing to replace
   */
  private function check_key($key) {

    if ($this->is_replacable($key) !== false) {

      $replaced = false;
      $this->replace_domain_string($key, $replaced);
      return $key;

    } else {

      return false;

    }

  }

  /**
   * logIterationError - Exception logged
   *
   * @param  {string} $e Exception or Throwable
   * @return @void
   */
  public function logIterationError($e) {

    $this->logger->log('Notice while importing: ' . $e->getMessage() . ' @ ' . $e->getLine() . ' (Code: ' . $e->getCode() . ')', 'WARN');
    // error_log(print_r($e, true));

  }

  /**
   * throwThisLine - Throws an exception for catch
   *
   * @return Throwable
   */
  public function throwThisLine($e) {

    // $this->logIterationError($e);
    throw new \ErrorException($e->getMessage(), 0, $e->getCode(), $e->getFile(), $e->getLine());

  }

  /**
   * @RECURSIVE MEMORY EATER
   * iterate_object - Recursive iterator of object/array
   * It checks every value and key and replaces it where needed
   *
   * @param  {?object/?mixed} &$object Mixed type, it will detect correct object and return no changed if wrong
   * @return {void} It modifies object or does nothing on wrong source
   */
  private function iterate_object(&$object) {

    if (is_string($object) && is_serialized($object)) {

      $this->iterate_maybe_serial_string($object);
      return;

    }

    if (!(is_object($object) || is_array($object))) {

      return;

    }

    try {

      foreach ($object as $key => $value) {

        $key_match = $this->check_key($key);
        if ($key_match !== false) {

          if (is_object($object)) {

            $object->$key_match = $object->$key;
            unset($object->$key);

          } elseif (is_array($object)) {

            $object[$key_match] = $object[$key];
            unset($object[$key]);

          }

          $key = $key_match;

        }

        if (($value && !is_string($value)) && (is_object($value) || is_array($value))) {

          try {

            $success = $this->iterate_object($value);
            if ($success === false) return false;

            if (is_object($object)) $object->$key = $value;
            elseif (is_array($object)) $object[$key] = $value;

          } catch (\Exception $e) { /* Errors? Probably here! */ $this->throwThisLine($e); return false; }
            catch (\Throwable $e) { /* Errors? Probably here! */ $this->throwThisLine($e); return false; }

        } else {

          // $subserial = $this->is_valid_serialized($value, false);
          // $subserial['valid'] === true
          if (is_serialized($value)) {

            try {

              $success = $this->iterate_object($value);
              if ($success === false) return false;

              if (is_object($object)) @$object->$key = $value;
              elseif (is_array($object)) $object[$key] = $value;

            } catch (\Exception $e) { /* Errors? Probably here! */ $this->throwThisLine($e); return false; }
              catch (\Throwable $e) { /* Errors? Probably here! */ $this->throwThisLine($e); return false; }

          } else {

            if (is_array($value) || is_object($value)) {

              try {

                $success = $this->iterate_object($value);
                if ($success === false) return false;

                if (is_object($object)) @($object->$key = $value);
                elseif (is_array($object)) $object[$key] = $value;

              } catch (\Exception $e) { /* Errors? Probably here! */ $this->throwThisLine($e); return false; }
                catch (\Throwable $e) { /* Errors? Probably here! */ $this->throwThisLine($e); return false; }


            } else {

              // Final string to replace (if string / else ignore)
              if (gettype($value) === 'string') {

                $modified = false;
                $this->replace_domain_string($value, $modified);

                if ($modified) {

                  try {

                    if (is_object($object)) @($object->$key = $value);
                    elseif (is_array($object)) $object[$key] = $value;

                  } catch (\Exception $e) { /* Errors? Probably here! */ $this->throwThisLine($e); return false; }
                    catch (\Throwable $e) { /* Errors? Probably here! */ $this->throwThisLine($e); return false; }

                }

              }

            }

          }

        }

      }

    } catch (\Exception $e) {

      $this->logIterationError($e);
      return false;

    } catch (\Throwable $e) {

      $this->logIterationError($e);
      return false;

    } catch (\ErrorException $e) {

      $this->logIterationError($e);
      return false;

    }

  }

  /**
   * line_replace - Value replacer before inserting to database
   *
   * @param  {string} &$line = '' Pointer to file line which should be replaced if needed
   * @return {void} It modifies pointer
   */
  private function line_replace(&$line = '') {

    // Remove wpdb prepare slashes to make sure the serialized data is correct
    $copy = $line;
    $line = stripcslashes($line);
    $line = str_replace('\0', '', $line);

    $serial = $this->is_valid_serialized($line);

    if ($serial['valid'] === true) {

      // Copy no needed here slashes will be added by wpdb
      unset($copy);

      // Iterate entire object replace keys and values
      $this->iterate_object($line);

      // Serialize it again as it was serialized before
      $line = serialize($line);

      // Make the string SQL friendly, as it was before
      if ($serial['quotes']) $line = $this->wpdb->prepare('%s', $line);
      if ($serial['comma']) $line .= ',';

    } else {

      // Make sure the line is exactly the same as in file
      $line = $copy;

      // Unset copy to free space
      unset($copy);

      // Find and replace domain
      $replaced = false;
      $this->replace_domain_string($line, $replaced);

    }

  }

  /**
   * run_query - Query runner, it runs the final query after built and replacements
   *
   * @param  {string} &$query Pointer to small or huge query
   * @return {void} Returns nothing, and the &$query is not removed here
   *
   * Notice: Please unset($query) after this method otherwise we will lose memory
   */
  private function run_query(&$query, $isAlter = false) {

    $this->xi++;
    if ($this->owndb === true) $this->db->query($query);
    else $this->wpdb->query($query);

    if ($this->xi % 5 == 0) {

      $from_start = number_format(microtime(true) - $this->init_start, 4);
      $filename = basename($this->current_file);

      $from_last = number_format(microtime(true) - $this->last_finish, 4);
      $this->last_finish = microtime(true);

      $queries_so_far = $this->xi;
      $queries_total = $this->total_queries;
      $queries_progress = number_format(($queries_so_far / $queries_total) * 100, 2);

      $halfProgress = true;
      if (isset($this->conversionStats['total_queries'])) {
        $halfProgress = false;
      }

      if (!$isAlter) {
        if ($halfProgress) {
          $this->logger->progress(50 + ($queries_progress / 2));
        } else {
          $this->logger->progress(75 + ($queries_progress / 4));
        }
      }

      $match_output = [];
      preg_match('/(.*)_(\d+)\_of\_(\d+)/', $filename, $match_output); // 1 (table name), 2 (part), 3 (total parts)

      $size = "";

      if (file_exists($this->current_file)) {
        $size = ", size: " . BMP::humanSize(filesize($this->current_file));
      }

      if (!$isAlter) {
        if (sizeof($match_output) >= 3) {

          $this->logger->log("Finished " . $queries_so_far . "/" . $queries_total . " (" . $queries_progress . "%)" . " for " . $match_output[1] . " table (part: " . $match_output[2] . "/" . $match_output[3] . $size . ").", 'INFO');

        } else {

          $this->logger->log("Finished " . $queries_so_far . "/" . $queries_total . " (" . $queries_progress . "%)" . " for " . $filename . " table (part: dynamic)", 'INFO');

        }
      }

    }

  }

  /**
   * load_file_list - Loads files to restore
   *
   * @return {array}  list list of restorable files
   */
  private function load_file_list() {

    $files = scandir($this->storage);
    foreach ($files as $index => $filename) {

      if (in_array($filename, ['.', '..'])) continue;

      $filePath = $this->storage . DIRECTORY_SEPARATOR . $filename;
      if (is_dir($filePath)) {

        $splitedFiles = scandir($filePath);
        foreach ($splitedFiles as $splitedIndex => $splitedFilename) {

          if (in_array($splitedFilename, ['.', '..', 'bmi_converted_completed_tables'])) continue;
          if (substr($splitedFilename, -4) != '.sql') continue;

          $this->files[] = $this->storage . DIRECTORY_SEPARATOR . $filename . DIRECTORY_SEPARATOR . $splitedFilename;

        }

        continue;

      }

      if (substr($filename, -4) != '.sql') continue;
      $this->files[] = $this->storage . DIRECTORY_SEPARATOR . $filename;

    }

    $this->sortFiles();

    return $this->files;

  }

  private function sortFiles() {

    $files = $this->files;

    $tmp_files = [];
    for ($i = 0; $i < sizeof($files); ++$i) {

      $file = $files[$i];

      $name = [];
      preg_match('/^(.*)\.sql$/', basename($file), $name); // 1

      $order = [];
      $name = $name[1];
      preg_match('/^(.*)\_((\d+)\_of\_(\d+))$/', $name, $order); // 1, 3, 4

      $tablename = $name;

      if (sizeof($order) >= 3) {
        $tablename = $order[1];
      }

      if (!isset($tmp_files[$tablename])) {
        $tmp_files[$tablename] = [];
      }

      $tmp_files[$tablename][] = $file;

    }

    ksort($tmp_files);
    $files = [];

    foreach ($tmp_files as $table => $sqlfiles) {

      usort($sqlfiles, function ($a, $b) {

        $nameA = [];
        preg_match('/^(.*)\.sql$/', basename($a), $nameA); // 1

        $nameB = [];
        preg_match('/^(.*)\.sql$/', basename($b), $nameB); // 1

        $nameA = $nameA[1];
        $nameB = $nameB[1];

        $orderA = [];
        preg_match('/^(.*)\_((\d+)\_of\_(\d+))$/', $nameA, $orderA); // 1, 3, 4

        $orderB = [];
        preg_match('/^(.*)\_((\d+)\_of\_(\d+))$/', $nameB, $orderB); // 1, 3, 4

        if (sizeof($orderA) >= 3 && sizeof($orderB) >= 3) {

          if (intval($orderA[3]) > intval($orderB[3])) {

            return 1;

          } else if (intval($orderA[3]) < intval($orderB[3])) {

            return -1;

          } else {

            return 0;

          }

        } else {

          return 1;

        }

      });

      for ($i = 0; $i < sizeof($sqlfiles); ++$i) {

        $files[] = $sqlfiles[$i];

      }

    }

    unset($tmp_files);

    $this->files = $files;
    return $this->files;

  }

  /**
   * is_valid_serialized - Checks if string is serialized and if it's valid serialization
   *
   * @param  {mixed} $data = '' Maybe serialized string
   * @return {array}            If the results is valid, if comma was removed, if quotes were removed
   */
  private function is_valid_serialized(&$data = '', $sql = true) {

    $commaRemoved = false;
    $quotesRemoved = false;

    if ($sql) {

      $length = strlen($data);
      $is_last = $data[$length - 1] == "," ? false : true;

      if (!$is_last) {

        $commaRemoved = true;
        $data = substr($data, 0, -1);

      }

    }

    if (is_numeric($data)) {

      if ($commaRemoved) $data .= ',';
      return ['valid' => false, 'comma' => false, 'quotes' => false];

    }

    if (!is_string($data)) {

      if ($commaRemoved) $data .= ',';
      return ['valid' => false, 'comma' => false, 'quotes' => false];

    }

    if (!$data) {

      if ($commaRemoved) $data .= ',';
      return ['valid' => false, 'comma' => false, 'quotes' => false];

    }

    if ($data[0] == "'") {

      $quotesRemoved = true;
      $data = trim($data, "'");

    }

    if (is_serialized($data)) {

      $data = preg_replace('!s:(\d+):"(.*?)";!e', "'s:'.strlen('$2').':\"$2\";'", $data);
      $unserialized = @unserialize($data, ['allowed_classes' => false]);

    } else {

      $unserialized = false;

    }

    $is_valid = ($data == 'b:0;' || ($unserialized !== false)) ? true : false;

    if (!$is_valid) {

      if ($quotesRemoved) {
        $data = $this->wpdb->prepare('%s', $data);
      }

      if ($commaRemoved) $data .= ',';

    } else {

      $data = $unserialized;
      unset($unserialized);

    }

    return ['valid' => $is_valid, 'comma' => $commaRemoved, 'quotes' => $quotesRemoved];

  }

}
