<?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\Progress\BMI_ZipProgress AS Progress;
use BMI\Plugin\Dashboard AS Dashboard;
use BMI\Plugin\Staging\BMI_Staging as Staging;
use BMI\Plugin\Backup_Migration_Plugin as BMP;

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

// echo "Memory usage at the beginning: " . (memory_get_usage() / 1024 / 1024) . " MB \n";
// function bmi_find_wordpress_base_path() {
//
//   $dir = dirname(__FILE__);
//   $previous = null;
//
//   do {
//
//     if (file_exists($dir . '/wp-config.php')) return $dir;
//     if ($previous == $dir) break;
//     $previous = $dir;
//
//   } while ($dir = dirname($dir));
//
//   return null;
//
// }
//
// define('BASE_PATH', bmi_find_wordpress_base_path() . '/');
// define('WP_USE_THEMES', false);
//
// // Use WP Globals and load WordPress
// global $wp, $wp_query, $wp_the_query, $wp_rewrite, $wp_did_header;
// require_once BASE_PATH . 'wp-load.php';
// echo "Memory usage after core load: " . (memory_get_usage() / 1024 / 1024) . " MB \n";
// ini_set('memory_limit', '2M');

/**
 * Database exporting
 * Main Class, requires $wpdb
 */
class BMI_Database_Exporter {

  /**
   * Private local variables
   */
  private $total_tables = 0;
  private $recipes = [];
  private $tables_by_size = [];
  public $total_queries = 0;
  public $total_rows = 0;
  public $total_size = 0;
  public $files = [];
  
  public $wpdb;
  public $logger;
  public $storage;
  public $percentage;
  public $max_rows;
  public $max_query_size;
  public $table_prefix;
  public $init_start;
  public $isPostRevisionsExcluded = false;

  /**
   * __construct - Initialization and logger resolver
   *
   * @return self
   */
  function __construct($storage, &$logger, $batcher = false, $backupStart = false) {

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

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

    /**
     * Storage directory
     */
    // $this->storage = trailingslashit(__DIR__) . 'data';
    $this->storage = $storage;

    /**
     * Percentage escape to replace
     * This way we know what the randomized string is
     */
     $this->percentage = trim($this->wpdb->prepare('%s', '%'), "'");

    /**
     * Max rows to pass each query
     */
    $this->max_rows = BMI_DB_MAX_ROWS_PER_QUERY;

    /**
     * Max size in bytes of single query export/import
     */
    $this->max_query_size = 1 * 1024 * 1024;

    $this->table_prefix = time();
    if ($backupStart && $backupStart !== false && is_numeric($backupStart)) {
      $this->table_prefix = $backupStart;
    }
    $this->init_start = microtime(true);
    if ($batcher === false || $batcher === 0) {
      $this->logger->log("Memory usage after initialization: " . number_format(memory_get_usage() / 1024 / 1024, 2) . " MB", 'INFO');
    }

    $isSmartExclusion =defined("BMI_BACKUP_PRO") && BMI_BACKUP_PRO && Dashboard\bmi_get_config('SMART:EXCLUSION:ENABLED') == 'true' ? true : false;
    $this->isPostRevisionsExcluded = $isSmartExclusion &&(Dashboard\bmi_get_config('SMART:EXCLUSION:PREVISIONS') == 'true' ? true : false);

  }

  /**
   * export - Export initializer
   *
   * @return filename/filenames
   */
  public function export($batchingStep = false, $indexEnded = 0) {

    // Table names
    $this->get_table_names_and_sizes($batchingStep);
    if ($batchingStep === false || $batchingStep === 0) {
      $this->logger->log("Scan found $this->total_tables tables ($this->total_rows rows), estimated total size: $this->total_size MB.", 'INFO');
      $this->logger->log("Memory usage after getting table names: " . number_format(memory_get_usage() / 1024 / 1024, 2) . " MB ", 'INFO');
    }

    // Recipes
    if ($batchingStep === false || $batchingStep === 0) {
      $this->logger->log("Getting table recipes...", 'INFO');
      $this->table_recipes();
      $this->logger->log("Table recipes have been exported.", 'INFO');
      $this->logger->log("Memory usage after loading recipes: " . number_format(memory_get_usage() / 1024 / 1024, 2) . " MB ", 'INFO');
    }

    // Save Recipes
    if ($batchingStep === false || $batchingStep === 0) {
      $this->logger->log("Saving recipes...", 'INFO');
      $this->save_recipes();
      $this->logger->log("Recipes saved.", 'INFO');
      $this->logger->log("Memory usage after recipe off-load: " . number_format(memory_get_usage() / 1024 / 1024, 2) . " MB", 'INFO');
    }

    // Tables data
    if ($batchingStep === false || $batchingStep === 0) {
      $this->logger->log("Exporting table data...", 'INFO');
    }
    $finishedAt = $this->get_tables_data($batchingStep, $indexEnded);
    if ($batchingStep === false || $finishedAt['dumpCompleted'] === true) {
      $this->logger->log("Table data exported.", 'INFO');
      $this->logger->log("Memory usage after data export: " . number_format(memory_get_usage() / 1024 / 1024, 2) . " MB", 'INFO');
    }

    if ($batchingStep === false) {
      $end = number_format(microtime(true) - $this->init_start, 4);
      $this->logger->log("Entire process took: $end s", 'INFO');
    }

    return $finishedAt;

  }
  
  /**
   * getPrefixesOfStagingSitesTables - Gets database prefixes of staging sites
   * 
   * @return {array} of prefixes (can be empty)
   */
  private function getPrefixesOfStagingSitesTables() {
    
    global $table_prefix;
    
    require_once BMI_INCLUDES . '/staging/controller.php';
    $staging = new Staging('..ajax..');
    $stagingSites = $staging->getStagingSites(true);
    $prefixes = [];
    
    foreach ($stagingSites as $name => $data) {
      if (!isset($data['db_prefix'])) continue;
      if (!$data['db_prefix']) continue;
      if (trim(strtolower($data['db_prefix'])) == '') continue;
      if (trim(strtolower($data['db_prefix'])) == trim(strtolower($table_prefix))) continue;
      if (isset($data['db_prefix'])) $prefixes[] = $data['db_prefix'];
    }
    
    return $prefixes;
    
  }
  
  /**
   * checkIfTableIsPartOfStaging - Compares table prefix with staging site prefixes
   * 
   * @return {bool} true/false
   */
  private function checkIfTableIsPartOfStaging(&$stagingSitePrefixes, $table) {
    
    for ($i = 0; $i < sizeof($stagingSitePrefixes); ++$i) { 
      $prefix = $stagingSitePrefixes[$i];
      if (substr($table, 0, strlen($prefix)) == $prefix) return true;
    }
    
    return false;
    
  }

  /**
   * get_table_names_and_sizes - Gets table names and sizes
   *
   * @return {array} associative array table_name => [size => its size in MB, rows => rows count]
   */
  private function get_table_names_and_sizes($batchingStep) {

    $tables = $this->wpdb->get_results('SHOW TABLES');
    $shouldExcludeTables = Dashboard\bmi_get_config('BACKUP:DATABASE:EXCLUDE');
    $stagingPrefixes = $this->getPrefixesOfStagingSitesTables();

    $excludedTables = [];
    $excludedTables = Dashboard\bmi_get_config('BACKUP:DATABASE:EXCLUDE:LIST');
    if (!is_array($excludedTables) || empty($excludedTables)) $excludedTables = [];
    
    foreach ($tables as $table_index => $table_object) {
      foreach ($table_object as $database_name => $table_name) {

        if (in_array($table_name, $excludedTables) && $shouldExcludeTables == 'true') {
          $str = __('Excluding %s table from backup (due to exclusion rules).', 'backup-backup');
          $str = str_replace('%s', $table_name, $str);
          if ($batchingStep === false || intval($batchingStep) === 0) $this->logger->log($str, 'INFO');
          continue;
        }
        
        if ($this->checkIfTableIsPartOfStaging($stagingPrefixes, $table_name)) {
          $str = __('Excluding %s table from backup (as it is part of staging site).', 'backup-backup');
          $str = str_replace('%s', $table_name, $str);
          if ($batchingStep === false || intval($batchingStep) === 0) $this->logger->log($str, 'INFO');
          continue;
        }

        $query = "SELECT table_name AS `table`, round(((data_length + index_length) / 1024 / 1024), 2) AS `size`, ";
        $query .= "(SELECT COUNT(*) FROM " . BMP::escapeSQLIDentifier($table_name) . ") AS `rows`";
        $query .= "FROM information_schema.TABLES ";
        $query .= "WHERE table_schema = %s AND table_name = %s";
        
        if ($this->isPostRevisionsExcluded && ($table_name == $this->wpdb->posts || $table_name == $this->wpdb->postmeta) && has_filter('bmip_smart_exclusion_post_revisions_count')) {
          $query = apply_filters('bmip_smart_exclusion_post_revisions_count', $query, $table_name);
        }

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Identifier is safely escaped via escapeSQLIDentifier()
        $results = $this->wpdb->get_results($this->wpdb->prepare($query, DB_NAME, $table_name));

        if (!is_object($results[0])) {
          if ($batchingStep === false || intval($batchingStep) === 0) {
            $this->logger->log("Could not get info about: $table_name (#01)", 'WARN');
          }
          continue;
        }

        $table_name_returned = trim($results[0]->table);
        if ($table_name != $table_name_returned || strlen(trim($table_name)) <= 0) {
          if ($batchingStep === false || intval($batchingStep) === 0) {
            $this->logger->log("Could not get info about: $table_name (#02)", 'WARN');
          }
          continue;
        }

        $this->tables_by_size[$table_name_returned] = array(
          'size' => floatval($results[0]->size),
          'rows' => intval($results[0]->rows)
        );

        $this->total_size += floatval($results[0]->size);
        $this->total_rows += intval($results[0]->rows);
        $this->total_tables++;

      }
    }

    return $this->tables_by_size;

  }

  /**
   * table_recipes - Gets CREATION recipe of each table
   *
   * @return {array} - Creation recipes for each table_name => recipe
   */
  private function table_recipes() {

    foreach ($this->tables_by_size as $table_name => $table_object) {

      // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Identifier is safely escaped via escapeSQLIDentifier()
      $result = $this->wpdb->get_results("SHOW CREATE TABLE " . BMP::escapeSQLIDentifier($table_name) . ";");
      foreach ($result as $index => $result_object) {
        foreach ($result_object as $column_name => $column_value) {

          if ($column_value == $table_name) continue;
          else {

            $column_value = str_replace(BMP::escapeSQLIDentifier($table_name), BMP::escapeSQLIDentifier($this->table_prefix . '_' . $table_name), $column_value);

            $recipe = 'CREATE TABLE IF NOT EXISTS ';
            $recipe .= substr($column_value, 13);
            $recipe = str_replace("\n ", "", $recipe);
            $recipe = str_replace("\n", "", $recipe);

            $this->recipes[$table_name] = $recipe;

          }

        }
      }

    }

    return $this->recipes;

  }

  /**
   * save_recipes - Save recipes and off-load the memory
   *
   * @return {void}
   */
  private function save_recipes() {

    $time_prefix = $this->table_prefix;
    foreach ($this->recipes as $table_name => $table_recipe) {

      $this->total_queries += 3;
      $recipe = "/* CUSTOM VARS START */\n";
      $recipe .= "/* REAL_TABLE_NAME: " . BMP::escapeSQLIDentifier($table_name) . "; */\n";
      $recipe .= "/* PRE_TABLE_NAME: " . BMP::escapeSQLIDentifier($time_prefix . '_' . $table_name) . "; */\n";
      $recipe .= "/* CUSTOM VARS END */\n\n";

      $recipe .= $table_recipe . ";\n";

      $this->total_rows++;
      $location = $this->file_name($table_name);
      $file = fopen($location, 'w');
              fwrite($file, $recipe);

      fclose($file);
      unset($file);

      $this->files[] = $location;
      unset($location);

    }

    unset($this->recipes);

  }

  private function getArraySize(&$a, $baseSize = 0) {

    $maxSize = $this->max_query_size;
    $totalSize = 0 + $baseSize;
    $i = 0;
    $reachedLimit = false;

    foreach ($a as $k => $v) {
      if (is_object($v) || is_array($v)) {
        $subSize = $this->getArraySize($v);
        $size = $subSize['size'];
        if (($totalSize + $size) > $maxSize && $totalSize != 0) {
          $reachedLimit = true;
          break;
        } else {
          $totalSize += $size;
          $i++;
        }
      } else if (strval($v)) {
        $totalSize += strlen($v);
      }
    }

    return [
      'size' => $totalSize,
      'index' => $i,
      'limit' => $reachedLimit
    ];

  }

  /**
   * get_tables_data - Table data getter
   *
   * @return {int} Total rows count
   */
  private function get_tables_data($batchingStep = false, $indexEnded = 0) {

    $finishedAt = 0;
    $currentTableIndex = 0;
    $dumpCompleted = true;

    foreach ($this->tables_by_size as $table_name => $table_object) {

      $emptyTable = false;
      $currentTableIndex = $currentTableIndex + 1;

      if ($batchingStep !== false) {
        if (intval($currentTableIndex - 1) !== intval($batchingStep)) {
          continue;
        } else {
          $dumpCompleted = false;
        }
      }

      $start_time = microtime(true);
      if ($batchingStep === false || intval($indexEnded) === 0) {
        $this->logger->log("Getting data of table: " . $table_name . " (" . $currentTableIndex . "/" . $this->total_tables . ", " . number_format($table_object['size'], 2) . " MB)", 'STEP');
      }
      $rows = intval($table_object['rows']);

      $this->wpdb->query("SET foreign_key_checks = 0;");

      $currentBufferSize = 0;
      $bufferResult = [];

      $i = 0;
      if ($batchingStep !== false) $i = $indexEnded;

      if (intval($table_object['rows']) > 0) {
        for (;$i < $rows;) {

          // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Identifier is safely escaped via escapeSQLIDentifier()
          $query = $this->wpdb->prepare("SELECT * FROM " . BMP::escapeSQLIDentifier($table_name) . " LIMIT %d, $this->max_rows", $i);
          if ($this->isPostRevisionsExcluded && ($table_name == $this->wpdb->posts || $table_name == $this->wpdb->postmeta) && has_filter('bmip_smart_exclusion_post_revisions')) {
            $query = apply_filters('bmip_smart_exclusion_post_revisions', $query, $table_name, $i, $this->max_rows);
          }
          // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared above, may be modified by trusted filter
          $result = $this->wpdb->get_results($query);

          $valuesSize = $this->getArraySize($result, $currentBufferSize);
          $rowsAmount = sizeof($result);
          $valuesBytesSize = $valuesSize['size'] - $currentBufferSize;
          $valuesMaxRow = $valuesSize['index'];
          $valuesLimit = $valuesSize['limit'];

          if ($valuesMaxRow < $rowsAmount && $valuesMaxRow != 0) $result = array_slice($result, 0, $valuesMaxRow);

          $i += $valuesMaxRow;
          $currentBufferSize += $valuesBytesSize;
          $finishedAt = $i;

          if ($valuesMaxRow != 0) $bufferResult = array_merge($bufferResult, $result);

          if ($currentBufferSize >= $this->max_query_size || $i >= $rows || $valuesLimit == true) {

            $currentBufferSize = 0;
            $this->save_data($bufferResult, $table_name);
            unset($bufferResult);
            $bufferResult = [];

            if ($batchingStep !== false) break;

          }

          unset($result);

        }

        $percentg = 100;
        if (intval($table_object['rows']) !== 0 && is_numeric(intval($table_object['rows']))) {

          $percentg = number_format(($i / intval($table_object['rows']) * 100), 2);

        }

        if ($i >= $rows && $batchingStep !== false) {

          $batchingStep = $batchingStep + 1;
          $finishedAt = 0;

          $this->logger->log("Milestone of table " . $table_name . ": " . $i . "/" . $table_object['rows'] . " rows (" . $percentg . "%, " . number_format((microtime(true) - $start_time), 5) . "s)", 'INFO');
          $this->logger->log("Table export for: " . $table_name . " finished", 'SUCCESS');

        } else if ($batchingStep !== false) {

          $this->logger->log("Milestone of table " . $table_name . ": " . $i . "/" . $table_object['rows'] . " rows (" . $percentg . "%, " . number_format((microtime(true) - $start_time), 5) . "s)", 'INFO');

        }

        $this->wpdb->query("SET foreign_key_checks = 1;");

        if ($batchingStep === false) {

          $this->logger->log("Table export for: " . $table_name . " finished (" . number_format((microtime(true) - $start_time), 5) . "s)", 'SUCCESS');

        }

        unset($start_time);

      } else {

        $this->logger->log("Table " . $table_name . " is empty, saving only recipe.", 'INFO');
        $emptyTable = true;

        if ($batchingStep !== false) {

          $batchingStep = $batchingStep + 1;
          $finishedAt = 0;

        }

      }

      if ($batchingStep !== false && $emptyTable === false) break;

    }

    return [
      'finishedQuery' => $finishedAt,
      'batchingStep' => $batchingStep,
      'dumpCompleted' => $dumpCompleted
    ];

  }

  /**
   * save_data - Saves table data/row as query
   *
   * @param  {wpdb object} &$result  Database query result
   * @param  {string} &$table_name   Table name
   * @return {void}
   */
  private function save_data(&$result, &$table_name) {

    $columns_schema_added = false;
    $file = fopen($this->file_name($table_name), 'a+');

    $this->total_queries++;
    $query = "INSERT INTO " . BMP::escapeSQLIDentifier($this->table_prefix . "_" . $table_name) . " ";

    foreach ($result as $index => $result_object) {

      $data_in_order = array();
      $format_in_order = array();
      $columns_in_order = array();

      foreach ($result_object as $column_name => $value) {

        $data_in_order[] = $value;
        $columns_in_order[] = "`$column_name`";

        if (is_numeric($value)) {

          if (is_float($value + 0)) $format_in_order[] = '%f';
          else $format_in_order[] = '%d';

        } else if (gettype($value) == 'NULL') {

          $format_in_order[] = '%null';

        } else $format_in_order[] = '%s';

      }

      if ($columns_schema_added === false) {

        $query .= "(" . implode(', ', $columns_in_order) . ") VALUES (";
        $columns_schema_added = true;

      } else {

        $query = "),(";

      }

      $columns = sizeof($columns_in_order);
      unset($columns_in_order);

      // $query .= "/* VALUES START */\n";
      for ($i = 0; $i < $columns; ++$i) {

        if ($format_in_order[$i] == '%f') {

          $floatresult = floatval($data_in_order[$i]);
          if (!is_infinite($floatresult) && is_numeric($floatresult)) {
            $query .= $floatresult;
          } else {
            if (defined('PHP_FLOAT_MAX')) $query .= PHP_INT_MAX;
            else $query .= PHP_FLOAT_MAX;
          }

        } elseif ($format_in_order[$i] == '%d') {

          $intresult = intval($data_in_order[$i]);
          if (!is_infinite($intresult) && is_numeric($intresult)) {
            $query .= $intresult;
          } else {
            $query .= PHP_INT_MAX;
          }

        } elseif ($format_in_order[$i] == '%null') {

          $query .= 'NULL';

        } else {

          $query .= $this->wpdb->prepare("%s", $data_in_order[$i]);
          $query = str_replace($this->percentage, '%', $query);

        }

        if ($i < ($columns - 1)) $query .= ",";

      }

      unset($data_in_order);
      unset($format_in_order);
      unset($columns_in_order);

      fwrite($file, $query);

    }

    // fwrite($file, ");\n/* QUERY END */\n\n");
    fwrite($file, ");\n");
    fclose($file);
    unset($file);

  }

  /**
   * file_name - Replaces table name to file name friendly format
   *
   * @param  {string} $table_name Table name
   * @return {string}             Friendly format for file
   */
  private function file_name($table_name) {

    $friendly_name = preg_replace("/[^A-Za-z0-9_-]/", '', $table_name);
    $friendly_name = trailingslashit($this->storage) . $friendly_name . '.sql';

    return $friendly_name;

  }

}
