#!@PHP_BIN@
<?php
$help = "NAME
    import-mysql-indexes - MySQL indexes import tool

DESCRIPTION
    This script ensures the existence of all indexes exported with
    export-mysql-indexes

SYNOPSIS
    import-mysql-indexes [OPTION]... -d DATABASENAME

OPTIONS
    -d, --dbname DATABASENAME
        The name of the database to import to

    -e, --exclude TABLENAME
        Excludes specified table from import. If you want to specify multiple
        tables you can use this option multiple times

    -h, --help
        Displays this help manual

    -H, --host HOSTNAME
        Connect to MySQL server on a host different than localhost. Hostname or
        IP address accepted

    -s, --sync
        Unless you use this option your database is not synchronized. This
        option actually ensures the existence of indexes fron the JSON file by
        creating missing indexes. If an index already exists but it is not
        identical with the one in the JSON file (i.e. columns are not in the
        same order), it is dropped and recreated
        
        If this option is not used differences are printed

    -i, --input FILENAME
        Read from a JSON file instead of standard input

    -p, --password PASSWORD
        Database password. If not specified attempting to connect without a
        password

    -u, --username USERNAME
        Specify username for the database connection. By default system username
        is used. On some systens the username cannot be determined. In this case
        use this option

NOTES
    The process usually takes several minutes BUT if your database contains a
    lot of data and no index exists the process may take up to 2 hours. Make
    sure you have enough free space on the disk because indexes may require a
    lot of space

AUTHOR
    Centreon (Alexandru Vilau)
";

$getHelp = "
To get help type:
    import-mysql-indexes --help
";

error_reporting(-1);
date_default_timezone_set(@date_default_timezone_get());

$options = (getopt('hd:u:p:H:e:i:s', array(
            'host:',
            'dbname:',
            'username:',
            'password:',
            'help',
            'exclude:',
            'input:',
            'sync'
        )));

/* In case help options */
if (isset($options['help']) || isset($options['h'])) {
    echo $help;
    exit;
}

/* Figuring out user options */
$host = isset($options['H']) ? $options['H'] : (isset($options['host']) ? $options['host'] : '127.0.0.1');
$host = is_array($host) ? end($host) : $host;

$dbname = isset($options['d']) ? $options['d'] : (isset($options['dbname']) ? $options['dbname'] : false);
$dbname = is_array($dbname) ? end($dbname) : $dbname;

$username = isset($options['u']) ? $options['u'] : (isset($options['username']) ? $options['username'] : (isset($_SERVER['LOGNAME']) ? $_SERVER['LOGNAME'] : false));
$username = is_array($username) ? end($username) : $username;

$password = isset($options['p']) ? $options['p'] : (isset($options['password']) ? $options['password'] : '');
$password = is_array($password) ? end($password) : $password;

$excludedTables = isset($options['e']) ? $options['e'] : (isset($options['exclude']) ? $options['exclude'] : array());
$excludedTables = is_array($excludedTables) ? $excludedTables : array($excludedTables);

$input = isset($options['i']) ? $options['i'] : (isset($options['input']) ? $options['input'] : false);
$input = is_array($input) ? end($input) : $input;

$sync = isset($options['s']) ? true : (isset($options['sync']) ? true : false);

if ($dbname === false) {
    echo "Error: No database name specified. Use -d option to specify it\n";
    echo $getHelp;
    exit(1);
}
if ($username === false) {
    echo "Error: No username specified. Use -u to specify it\n";
    echo $getHelp;
    exit(1);
}

try {

    $data = @file_get_contents($input ? $input : 'php://stdin');

    if (!$data) {
        echo "Cannot read input\n";
        exit(1);
    }

    if (!function_exists('json_decode')) {
        if (!(@include 'Services/JSON.php') || !class_exists('Services_JSON')) {
            echo "Please insstall Services_JSON pear package\n    pear install Services_JSON\n";
            exit(1);
        }
        $json = new Services_JSON();
        $indexes = $json->decode($data);
    } else {
        $indexes = json_decode($data);
    }

    if (!$indexes) {
        echo "Something went wrong while parsing stdin. Bad JSON file\n";
        exit(1);
    }

    if (!is_array($indexes)) {
        echo "Huston, we have a problem! JSON file contains no array\n";
        exit(1);
    }

    $conn = new PDO('mysql:host=' . $host . ';dbname=' . $dbname, $username, $password);
    /* Class defined at the end of this file */
    $importer = new MysqlIndexImporter($conn, $indexes, $dbname);
    $importer->setExcludedTables($excludedTables);
    $importer->setSync($sync);
    $importer->import();
    $exitCode = 0;

    $differences = false;
    if (!$sync) {
        foreach ($importer->getDifferences() as $diff) {
            echo $diff . "\n";
            $differences = true;
        }

        if ($differences) {
            echo "\nUse --sync option to sync indexes\n";
        } else {
            echo "No differences detected\n";
        }
    } else {
        foreach ($importer->getLogs() as $log) {
            if (!$log['success']) {
                $exitCode = 1;
            }
            echo '[' . date('Y-m-d H:i:s', $log['time']) . ']' . ($log['success'] ? ' OK ' : ' NOK ') . $log['message'] . "\n";
        }
    }
} catch (Exception $e) {
    echo 'Something went wrong: ' . $e->getMessage() . "\n";
    exit(1);
}

/* Returning exit code */
exit($exitCode);

/**
 * Used to import a formatted array containing database indexes for multiple
 * tables into a MySQL database
 */
class MysqlIndexImporter
{
    /**
     * Database connection
     * @var PDO
     */
    public $connection;

    /**
     * Database name used for select statements in information_schema MySQL database
     * @var string
     */
    public $dbname;

    /**
     * Used to store differences between indexes
     * @var string[]
     */
    protected $differences = array();

    /**
     * Array of string containing table names to exclude
     * @var array 
     */
    protected $excludedTables;

    /**
     * An array containing database indexes
     * Format:
     *  array(
     *       stdClass{
     *          'tableName' => string $tableName,
     *          'indexName' => string $indexName,
     *          'unique'    => bool $unique,
     *          'columns'   => array(
     *              stdClass{
     *                  'name' => string $name,
     *                  'length' => int $length
     *              },
     *              ...
     *          )
     *      },
     *      ...
     *  )
     * @var array 
     */
    public $indexes = array();

    /**
     * Array of logs
     * Format:
     *  array(
     *      array(
     *          'success' => bool $success
     *          'time' => int $timestamp,
     *          'message' => string $message
     *      ),
     *      ...
     *  )
     * @var array
     */
    protected $logs = array();

    /**
     * Only if this property is true database is altered as a safety measure
     * @var bool 
     */
    protected $sync = false;

    /**
     * One instance per database
     * @param PDO $connection Database connection
     * @param array $indexes Contains list of database indexes
     * @param string $dbname Database name
     * @throws InvalidArgumentException In case arguments incompatible
     */
    public function __construct(PDO $connection, array $indexes, $dbname)
    {
        if (!is_string($dbname)) {
            throw new InvalidArgumentException('Database name must be string');
        }
        $this->connection = $connection;
        $this->indexes = $indexes;
        $this->dbname = $dbname;
    }

    /**
     * Ensures that all provided indexes exist in the database
     */
    public function import()
    {
        /* Retrieving all index columns in destination database */
        $stmt = $this->connection->prepare("SELECT
                                                    *
                                                FROM
                                                    information_schema.STATISTICS
                                                WHERE
                                                    TABLE_SCHEMA = ? AND
                                                    TABLE_NAME = ? AND
                                                    INDEX_NAME = ?
                                                ORDER BY
                                                    SEQ_IN_INDEX ASC");
        if (!$stmt) {
            throw new Exception("Cannot prepare statement to select indexes");
        }

        /* Looping on all indexes in the source array */
        foreach ($this->indexes as $index) {
            if (in_array($index->tableName, $this->excludedTables)) {
                continue;
            }
            if (!$stmt->execute(array($this->dbname, $index->tableName, $index->indexName))) {
                $this->logs[] = array(
                    'success' => false,
                    'time' => time(),
                    'message' => "Cannot execute select statement for index '{$index->indexName}' from table '{$index->tableName}'"
                );
                continue;
            }

            $columns = $stmt->fetchAll(PDO::FETCH_ASSOC);

            /* If no column then index does not exist */
            if (!count($columns)) {
                if ($this->sync) {
                    $this->createIndexFromStructure($index);
                } else {
                    $this->registerDifference($index, $columns);
                }
                continue;
            }
            /* If no column count differs then index is different */
            if (count($index->columns) !== count($columns)) {
                if ($this->sync) {
                    $this->recreateIndexFromStructure($index);
                } else {
                    $this->registerDifference($index, $columns);
                }
                continue;
            }

            $i = 0;
            /**
             * Looping through all collumns of the index to search if there
             * are any differences
             */
            foreach ($index->columns as $column) {
                /**
                 * This is normally done once per index but since mysql stores 
                 * indexes in a redundant way just to be sure we check the 
                 * uniqueness for every column.
                 */
                if ($index->unique xor !$columns[$i]['NON_UNIQUE']) {
                    if ($this->sync) {
                        $this->recreateIndexFromStructure($index);
                    } else {
                        $this->registerDifference($index, $columns);
                    }
                    break;
                }
                /* In case column name differs */
                if ($column->name !== $columns[$i]['COLUMN_NAME']) {
                    if ($this->sync) {
                        $this->recreateIndexFromStructure($index);
                    } else {
                        $this->registerDifference($index, $columns);
                    }
                    break;
                }
                /* In case column length differs */
                if ($column->length !== $columns[$i]['SUB_PART']) {
                    if ($this->sync) {
                        $this->recreateIndexFromStructure($index);
                    } else {
                        $this->registerDifference($index, $columns);
                    }
                    break;
                }
                ++$i;
            }
        }
    }

    /**
     * Drops an index from the database
     * @param array $index Only "indexName' key used
     */
    public function dropIndex($index)
    {
        if ($index->indexName === 'PRIMARY') {
            $query = "ALTER TABLE `{$index->tableName}` DROP PRIMARY KEY";
        } else {
            $query = "ALTER TABLE `{$index->tableName}` DROP KEY `{$index->indexName}`";
        }

        $result = $this->connection->exec($query);

        if ($result === false) {
            $error = $this->connection->errorInfo();
            $this->logs[] = array(
                'success' => false,
                'time' => time(),
                'message' => "Failed to drop index '{$index->indexName}' for table '{$index->tableName}: " . $error[2]
            );
            return false;
        }
        $this->logs[] = array(
            'success' => true,
            'time' => time(),
            'message' => "Successfully dropped index '{$index->indexName}' for table '{$index->tableName}'; $result rows affected"
        );
        return true;
    }

    /**
     * Creates an index in the database
     * @param array $index
     */
    public function createIndexFromStructure($index)
    {
        $keyPart = '';
        if ($index->unique) {
            $keyPart = 'UNIQUE';
        }
        if ($index->indexName === 'PRIMARY') {
            $keyPart = 'PRIMARY';
        }

        /**
         * Building columns part
         */
        $columnParts = array();
        foreach ($index->columns as $column) {
            if ($column->length) {
                $length = ' (' . (int) $column->length . ')';
            } else {
                $length = '';
            }
            $columnParts[] = '`' . $column->name . '`' . $length;
        }
        $columnPart = '(' . implode(', ', $columnParts) . ')';

        $result = $this->connection->exec("ALTER TABLE `{$index->tableName}` ADD $keyPart KEY `{$index->indexName}` $columnPart");

        /**
         * Hinting index type in logs
         */
        $indexTypeText = '';
        if ($index->unique) {
            $indexTypeText = 'unique ';
        }
        if ($index->indexName === 'PRIMARY') {
            $indexTypeText = 'primary ';
        }

        if ($result === false) {
            $error = $this->connection->errorInfo();
            $this->logs[] = array(
                'success' => false,
                'time' => time(),
                'message' => "Failed to create {$indexTypeText}index '{$index->indexName}' $columnPart for table '{$index->tableName}': " . $error[2]
            );
            return false;
        }
        $this->logs[] = array(
            'success' => true,
            'time' => time(),
            'message' => "Successfully created {$indexTypeText}index '{$index->indexName}' $columnPart for table '{$index->tableName}'; $result rows affected"
        );
        return true;
    }

    /**
     * Drops and creates index as defined in $index
     * @param array $index
     */
    public function recreateIndexFromStructure($index)
    {
        /**
         * Any scenarios when a dropped index cannot be recreated?
         * Are transacrions needed?
         */
        if ($this->dropIndex($index)) {
            $this->createIndexFromStructure($index);
        }
    }

    /**
     * Registers a difference as a string to be displayed for the end user
     * @param stdClass $indexStructure
     * @param array $databaseIndexColumns
     * @return type
     */
    protected function registerDifference($indexStructure, $databaseIndexColumns)
    {
        /* Making string with column names in order for source */
        $leftColumnParts = array();
        foreach ($indexStructure->columns as $column) {
            if ($column->length) {
                $length = ' (' . (int) $column->length . ')';
            } else {
                $length = '';
            }
            $leftColumnParts[] = '`' . $column->name . '`' . $length;
        }

        /* In case index does not exist no need to go further */
        if (count($databaseIndexColumns) === 0) {
            $this->differences[] = 'Missing: ' . ($indexStructure->unique ? 'UNIQUE ' : '') . 'INDEX \'' . $indexStructure->tableName . '\'.\'' . $indexStructure->indexName . '\'(' . implode(', ', $leftColumnParts) . ')';
            return;
        }

        /* Making string with column names in order for destination */
        $rightColumnParts = array();
        foreach ($databaseIndexColumns as $databaseIndexColumn) {
            if ($databaseIndexColumn['SUB_PART']) {
                $length = ' (' . (int) $databaseIndexColumn['SUB_PART'] . ')';
            } else {
                $length = '';
            }
            $rightColumnParts[] = '`' . $databaseIndexColumn['COLUMN_NAME'] . '`' . $length;
        }

        /* Getting first index column to access rendundant data */
        $firstColumn = reset($databaseIndexColumns);

        /* Adding difference text */
        $this->differences[] = 'Difference: ' . (!$firstColumn['NON_UNIQUE'] ? 'UNIQUE ' : '') . 'INDEX \'' . $indexStructure->tableName . '\'.\'' . $indexStructure->indexName . '\'(' . implode(', ', $rightColumnParts) . ') should be ' . ($indexStructure->unique ? 'UNIQUE ' : '') . 'INDEX(' . implode(', ', $leftColumnParts) . ')';
    }

    /**
     * Getter for differences array
     * @return array
     */
    public function getDifferences()
    {
        return $this->differences;
    }

    /**
     * Setter for sync option
     * @param bool $sync
     */
    public function setSync($sync)
    {
        $this->sync = (bool) $sync;
    }

    /**
     * Sets the list of tables to be excluded from import
     * @param array $excludedTables
     * @throws InvalidArgumentException
     */
    public function setExcludedTables($excludedTables)
    {
        if (!is_array($excludedTables)) {
            throw new InvalidArgumentException('List of tables to be excluded should be an array of strings');
        }
        $this->excludedTables = $excludedTables;
    }

    /**
     * Returns error messages
     * @return array
     */
    public function getLogs()
    {
        return $this->logs;
    }

}
