#!/usr/bin/env php
<?php
/**
 * mariabak
 *
 * mariabak is a command-line script to make MariaDB/MySQL database backup a breeze, using mysqldump.
 *
 * @package    mariabak
 * @version    1.4.2
 * @author     Lawrence Lagerlof <llagerlof@gmail.com>
 * @link       http://github.com/llagerlof/mariabak
 * @license    https://opensource.org/licenses/MIT MIT
 */

// Let the application deal with the warning generated by mkdir
error_reporting(E_ALL ^ E_WARNING);

// Check if php exist on PATH
if (!command_exists('php --version')) {
    die("> 'php': command unavailable. Must be on on PATH. \n");
}

// Check if mysqldump exist on PATH
if (!command_exists('mysqldump --version')) {
    die("> 'mysqldump': command unavailable. Must be on on PATH (mysqldump is part of MariaDB/MySQL client). \n");
}

// Selected databases
$databases_selected = pvalues('--databases');

// Selected tables to ignore data, but keeping structure
$tables_ignored_data = pvalues('--ignore-tables');

// Connection details
$host = pvalue('--host') ?: 'localhost';
$port = pvalue('--port') ?: '3306';
$user = pvalue('--user') ?: 'root';
$password = pvalue('--password') ?: '';
$password_interactive = pvalue('-p');

// Just list the databases
$list_databases = pvalue('-list');

// Validate if any required option were provided. If not, show help.
if (!$databases_selected && $list_databases !== true) {
    echo "\n> mariabak 1.4.2: a command-line script to make MariaDB/MySQL database backup a breeze, using mysqldump.\n\n";
    echo "  Usage:\n\n";
    echo "    List databases:\n\n";
    echo "      $ mariabak -list          # if you used the installer\n\n";
    echo "      $ php mariabak.php -list  # if you didn't use the installer\n\n";
    echo "    Backup all databases. A directory will be created in current directory:\n\n";
    echo "      $ mariabak --databases=*\n\n";
    echo "    Backup one database, asking for the server password interactively:\n\n";
    echo "      $ mariabak --databases=db1 --host=localhost --user=root -p\n\n";
    echo "    Backup some databases, ignore some tables data but preserve its structure:\n\n";
    echo "      $ mariabak --databases=db1,db2,db3 --ignore-tables=db2.table1,db2.table2,db3.table_a\n\n";
    echo "    Backup one database, passing the server password inline:\n\n";
    echo "      $ mariabak --databases=db1 --host=localhost --port=3306 --user=root --password=hunter2\n\n";
    die();
}

/*
    Ask for the password (-p)

    - Hiding the password only works on Linux shell.
    - The Linux read command performs a trim() on typed string, so
      if your password start or end with spaces, use --password=" yourpassword " instead.
*/
if ($password_interactive === true) {
    if (strtolower(php_uname('s')) == 'linux') {
        echo 'Enter password: ';
        // Thank you, Antony Penn, for the hidden typing technique (https://www.php.net/manual/en/function.readline.php)
        $handle = popen("read -s; echo \$REPLY", 'r');
        // read command adds a line break at the end of string, so it must be removed.
        $password = str_replace(PHP_EOL, '', fgets($handle, 256));
        pclose($handle);
    } else {
        $password = readline('Enter password (CAUTION: the password will be printed while you type): ');
    }
}

// Connect to MariaDB database using PDO
try {
    $db = new PDO('mysql:host=' . $host . ':' . $port, $user, $password);
} catch (Exception $e) {
    die("> Error connecting to database server.  (exception message: " . $e->getMessage() . ")\n");
}

// Get all databases names
$databases = statement("show databases where `Database` not in ('mysql', 'performance_schema', 'information_schema')", "Error retrieving database list.")->fetchAll(PDO::FETCH_COLUMN);

// List databases and exit
if ($list_databases === true) {
    echo("> Databases:\n");
    foreach ($databases as $database) {
        echo("  - " . $database . "\n");
    }
    die();
}

// Get the @@GLOBAL.basedir to use on directory name
$basedir = statement('select @@GLOBAL.basedir as basedir', 'Error retrieving @@GLOBAL.basedir.')->fetchColumn();

// Get the @@GLOBAL.datadir to use on directory name
$datadir = statement('select @@GLOBAL.datadir as datadir', 'Error retrieving @@GLOBAL.datadir.')->fetchColumn();

// Get all system variables. Will be included in SYSTEM_VARIABLES.txt
$system_variables = statement('show variables','Error retrieving system variables.')->fetchAll(PDO::FETCH_ASSOC);
$csv_system_variables = array2csv($system_variables);

// Get all users and hosts. Will be included in USER_HOSTS.txt
$user_hosts = statement('select distinct u.user as user, u.host as host from mysql.user u', 'Error retrieving users and hosts.')->fetchAll(PDO::FETCH_ASSOC);
$csv_user_hosts = array2csv($user_hosts);

// Get all grants for $user_hosts. Will be included in GRANTS.txt
$grants_commands = '';
foreach ($user_hosts as $user_host) {
    $grants_commands .= $user_host['user'] . '@' . $user_host['host'] . "\n\n";

    $grants = statement("show grants for '" . $user_host['user'] . "'@'" . $user_host['host']."'", 'Error retrieving grants.')->fetchAll(PDO::FETCH_COLUMN);
    foreach ($grants as $grant) {
        $grants_commands .= $grant . "\n";
    }
    $grants_commands .= "\n\n";
}

// Check if at least one database was returned
if (count($databases) === 0) {
    die("> No databases found.\n");
}

// Make sure one or more selected databases exists
if (in_array('*', $databases_selected)) {
    $databases_selected = $databases;
} else {
    $databases_selected = array_intersect($databases, $databases_selected);
}

// Check if at least one database was selected
if (empty($databases_selected)) {
    die("> No database(s) selected.\n");
}

// Print selected databases
echo("\n> Selected databases:\n");
foreach ($databases_selected as $database) {
    echo("  - " . $database . "\n");
}

echo "\n";

// Create the backup directory
$backup_dir_basename  = build_directory_name(['host' => $host, 'port' => $port, 'basedir' => $basedir, 'datadir' => $datadir]);
$backup_dir = getcwd() . "/$backup_dir_basename";

if (file_exists($backup_dir) && !is_dir($backup_dir)) {
    die("> Error: Directory '$backup_dir_basename' could not be created because a file with the same name already exists.\n");
}

if (!is_dir($backup_dir)) {
    if (!is_writable(getcwd())) {
        die("> Error: Directory '$backup_dir_basename' could not be created. Permission denied.\n");
    } else {
        echo("> BACKUP STARTED...\n\n");
        echo "> Creating backup directory '$backup_dir_basename' ...\n";
        if (!mkdir($backup_dir, 0776)) {
            die("> Error: Directory '$backup_dir_basename' could not be created (maybe 'dirnameformat' in .mariabak.conf has invalid characters?)\n");
        }
    }
}

// Check if backup directory already have backup files (.sql)
if (!empty(glob("$backup_dir/*.sql"))) {
    echo "> Warning: Directory '$backup_dir_basename' already contains backup files.\n\n";
    $a = readline("Overwrite existing files? (y/n) [default n]: ");
    if (trim(strtolower($a)) != 'y') {
        die("\n> Backup cancelled.\n");
    }
}

// Identify databases with events
$events = statement('select event_schema from information_schema.events', 'Error retrieving events.')->fetchAll(PDO::FETCH_COLUMN);

// Make a backup of each database in the list to a separate file using mysqldump
foreach ($databases_selected as $database) {
    echo "\n> Backuping database '{$database}' ... ";

    // Backup structure
    $cmd_structure = "mysqldump --single-transaction --skip-triggers --no-data --host=\"$host\" --port=\"$port\" --user=\"$user\" --password=\"$password\" $database > \"$backup_dir/$database.sql\"";

    exec($cmd_structure, $output, $result_code);

    check_mysqldump_error($result_code);

    $ignored_tables_arguments = ignored_tables_arguments($database);

    // Backup data
    $cmd_data = "mysqldump --single-transaction --routines --triggers --no-create-info $ignored_tables_arguments --host=\"$host\" --port=\"$port\" --user=\"$user\" --password=\"$password\" $database >> \"$backup_dir/$database.sql\"";

    exec($cmd_data, $output, $result_code);

    check_mysqldump_error($result_code);

    // Backup events
    if (in_array($database, $events)) {
        $cmd_events = "mysqldump --no-create-db --no-create-info --no-data --skip-triggers --events --host=\"$host\" --port=\"$port\" --user=\"$user\" --password=\"$password\" $database > \"$backup_dir/$database.events.sql\"";

        exec($cmd_events, $output, $result_code);

        check_mysqldump_error($result_code);
    }

    echo "done.\n";
}

// Backup system variables
echo "\n> Backuping system variables to 'SYSTEM_VARIABLES.txt' ... ";
file_put_contents("$backup_dir/SYSTEM_VARIABLES.txt", $csv_system_variables);
echo "done.\n";

// Backup users and hosts
echo "\n> Backuping users and hosts to 'USER_HOSTS.txt' ... ";
file_put_contents("$backup_dir/USERS_HOSTS.txt", $csv_user_hosts);
echo "done.\n";

// Backup grants
echo "\n> Backuping grants to 'GRANTS.txt' ... ";
file_put_contents("$backup_dir/GRANTS.txt", $grants_commands);
echo "done.\n";

// Backup connection details
echo "\n> Backuping connection details (host, port, user) to 'CONNECTION_DETAILS.txt' ... ";
$connection_details = "backup finished at: " . date('Y-m-d H:i:s') . "\n\nhost: $host\nport: $port\nuser: $user\n";
file_put_contents("$backup_dir/CONNECTION_DETAILS.txt", $connection_details);
echo "done.\n";

die("\n> BACKUP FINISHED SUCCESSFULLY.\n\n");


/* Utility functions */

/**
 * Convert string to a valid filename
 *
 * @param string $str Any string
 *
 * @return string Valid and safe filename
 */
function str2filename(string $str): string
{
    // Replace ":\" with "."
    $str = preg_replace('/:\\\/', '.', $str);
    // Replace "\" with "."
    $str = preg_replace('/\\\/', '.', $str);
    // Replace "/" with "."
    $str = preg_replace('/\//', '.', $str);
    // Replace all spaces with underscores
    $str = str_replace(' ', '_', $str);
    // Replace all non alphanumeric characters, not including "-", "_" and ".", with dashes
    $str = preg_replace('/[^A-Za-z0-9\-_\.]/', '-', $str);
    // Replace a sequence of "." with one "."
    $str = preg_replace('/\.+/', '.', $str);
    // Replace a sequence of blank characters with one space
    $str = preg_replace('/\s+/', ' ', $str);
    // Replace a sequence of underscores with one underscore
    $str = preg_replace('/_+/', '_', $str);
    // Replace a sequence of hiphens with one hiphen
    $str = preg_replace('/-+/', '-', $str);
    // Remove all "-", "_" and "." from the end of the string
    $str = preg_replace('/[\-_\.]+$/', '', $str);
    // Remove all "-", "_" and "." from the start of the string
    $str = preg_replace('/^[\-_\.]+/', '', $str);

    return trim($str);
}

/**
 * Convert a 2d array, table-like, to CSV format
 *
 * @param array $array_2d
 *
 * @return string CSV
 */
function array2csv(array $array_2d): string
{
    if (!is_array($array_2d)) {
        return '';
    }
    if (empty($array_2d)) {
        return '';
    }

    $csv = '';
    $column_position = 1;
    $column_count = count($array_2d[0]);
    foreach ($array_2d[0] as $column => $value) {
        $csv .= $column . ($column_position != $column_count ? ',' : "\n");
        $column_position++;
    }
    foreach ($array_2d as $row) {
        $csv .= implode(',', $row) . "\n";
    }

    return $csv;
}

/**
 * Build a string to represent the ignored tables arguments
 *
 * @param string $database The database name.
 *
 * @return string
 */
function ignored_tables_arguments($database): string
{
    global $tables_ignored_data;

    if (trim($database) == '' || empty($tables_ignored_data)) {
        return '';
    }

    $ignored_filtered = array_filter($tables_ignored_data, function($table) use ($database) {
        return preg_match('/^' . $database . '\./', $table);
    });

    $built_arguments = array_map(function($table_name) {
        return '--ignore-table=' . $table_name;
    }, $ignored_filtered);

    return implode(' ', $built_arguments);
}

/**
 * Parameter value. Return the parameter value passed to the script, or false if not found in the command line arguments.
 *
 * @param string $param
 *
 * @return string|bool
 */
function pvalue(string $param)
{
    global $argv;

    $param_value = false;
    foreach($argv as $arg) {
        if (preg_match('/^'.$param . '=' . '/', $arg)) {
            $param_pieces = explode("=", $arg);
            $param_value = $param_pieces[1] ?? '';
        } elseif (preg_match('/^' . $param . '/', $arg)) {
            $param_value = true;
        }
    }

    return $param_value;
}

/**
 * Parameter values. Return an array with all the parameter comma-separated values.
 *
 * @param string $param
 *
 * @return array
 */
function pvalues(string $param): array
{
    global $argv;

    $param_value = pvalue($param);

    return array_filter(explode(',', $param_value));
}

/**
 * Execute a query and return a PDOStatement object.
 *
 * @param string $query
 * @param string $error_message
 *
 * @return PDOStatement
 */
function statement(string $query, string $error_message): PDOStatement
{
    global $db;

    try {
        $stmt = $db->query($query);
    } catch (Exception $e) {
        die("> " . $error_message . "  (exception message: " . $e->getMessage() . ")\n");
    }

    return $stmt;
}

/**
 * Check if the result code from exec('mysqldump') returned a number different than zero (so an error occurred)
 *
 * @param integer $result_code
 *
 * @return void
 */
function check_mysqldump_error(int $result_code): void
{
    global $backup_dir_basename;

    if ($result_code !== 0) {
        echo "\n> Error during mysqldump execution: code $result_code\n\n";
        echo "> Delete manually the incomplete backup directory '$backup_dir_basename'\n\n";
        die("\n> BACKUP FAILED!\n\n");
    }
}

/**
 * Get the current operating system name (linux, windows or darwin)
 *
 * @return string The current OS name
 */
function current_os(): string
{
    $os = strtolower(php_uname('s'));

    if ((strpos($os, 'windows') !== false) || (strpos($os, 'winnt') !== false)) {
        $os_name = 'windows';
    } elseif (strpos($os, 'darwin') !== false) {
        $os_name = 'darwin';
    } else {
        // linux, cygwin and "others"
        $os_name = 'linux';
    }

    return $os_name;
}

/**
 * Check if a command exists under Linux and Windows' cmd
 *
 * @param string $command
 *
 * @return bool
 */
function command_exists(string $command): bool
{
    switch(current_os()) {
        case 'linux':
            $null_suffix = ' 2>&1';
            break;
        case 'windows':
            $null_suffix = ' >NUL 2>&1';
            break;
        default:
            $null_suffix = '';
    }

    exec("$command $null_suffix", $output, $result_code);

    return $result_code === 0;
}

/**
 * Get the user home directory
 *
 * @return string
 */
function user_home_dir(): string
{
    switch(current_os()) {
        case 'windows':
            return getenv('USERPROFILE');
            break;
        default:
            return getenv('HOME');
    }
}

/**
 * Read the configuration file '.mariabak.conf' in the user home directory
 *
 * return array
 */
function read_config(): array
{
    $config_default['dateformat'] = 'Y-m-d';
    $config_default['timeformat'] = 'H-i-s';
    $config_default['dirnameformat'] = 'backup-db_{date}_{time}_{host}_{port}';
    $config = [];

    if (file_exists(user_home_dir() . '/.mariabak.conf')) {
        $config = parse_ini_file(user_home_dir() . '/.mariabak.conf');
    }

    foreach ($config_default as $key => $value) {
        if (!array_key_exists($key, $config)) {
            $config[$key] = $value;
        }
    }

    return $config;
}

/**
 * Generate the backup directory name
 *
 * @param array $data_fields All the placeholders values
 *
 * @return string Directory name
 */
function build_directory_name(array $data_fields): string
{
    $config = read_config();
    $dirname = $config['dirnameformat'];

    preg_match_all('/{.+?}/', $config['dirnameformat'], $placeholders);

    foreach ($placeholders[0]??[] as $placeholder) {
        switch ($placeholder) {
            case '{date}':
                $dirname = str_replace($placeholder, date($config['dateformat']), $dirname);
                break;
            case '{time}':
                $dirname = str_replace($placeholder, date($config['timeformat']), $dirname);
                break;
            case '{host}':
                $dirname = str_replace($placeholder, $data_fields['host'], $dirname);
                break;
            case '{port}':
                $dirname = str_replace($placeholder, $data_fields['port'], $dirname);
                break;
            case '{basedir}':
                $dirname = str_replace($placeholder, str2filename($data_fields['basedir']), $dirname);
                break;
            case '{datadir}':
                $dirname = str_replace($placeholder, str2filename($data_fields['datadir']), $dirname);
                break;
        }
    }

    if (trim($dirname) == '') {
        die("> Error: Backup directory name not set. Did you left 'dirnameformat' with an empty value in .mariabak.conf ?\n");
    }

    return $dirname;
}