<?php
/**
 * For the full copyright and license information, please view the
 * docs/licenses/LICENSE.txt file that was distributed with this source code.
 */

namespace Tests\Resources;

use Cache;
use CMSCategory;
use CMSRole;
use Combination;
use Connection;
use Db;
use Exception;
use Language;
use Order;
use PrestaShop\PrestaShop\Core\Util\Inflector;
use PrestaShop\PrestaShop\Core\Version;
use PrestaShopLogger;
use ProductAttribute;

class DatabaseDump
{
    /**
     * Database host
     *
     * @var string
     */
    private $host;

    /**
     * Database port
     *
     * @var int|string
     */
    private $port;

    /**
     * Database user
     *
     * @var string
     */
    private $user;

    /**
     * Database password
     *
     * @var string
     */
    private $password;

    /**
     * Database name
     *
     * @var string
     */
    private $databaseName;

    /**
     * Database prefix for table names
     *
     * @var string
     */
    private $dbPrefix;

    /**
     * Generic dump file path (dump of the whole database)
     *
     * @var string
     */
    private $dumpFile;

    /**
     * Db instance to perform queries
     *
     * @var Db
     */
    private $db;

    /**
     * Constructor extracts database connection info from PrestaShop's configuration,
     * but we use mysqldump and mysql for dump / restore.
     *
     * @param string $dumpFile dump file name
     */
    private function __construct($dumpFile = null)
    {
        $host_and_maybe_port = explode(':', _DB_SERVER_);

        if (count($host_and_maybe_port) === 1) {
            $this->host = $host_and_maybe_port[0];
            $this->port = 3306;
        } elseif (count($host_and_maybe_port) === 2) {
            $this->host = $host_and_maybe_port[0];
            $this->port = $host_and_maybe_port[1];
        }

        $this->databaseName = _DB_NAME_;
        if ($dumpFile === null) {
            $this->dumpFile = sprintf('%s/ps_dump_%s_%s.sql', sys_get_temp_dir(), $this->databaseName, Version::VERSION);
        } else {
            $this->dumpFile = $dumpFile;
        }
        $this->user = _DB_USER_;
        $this->password = _DB_PASSWD_;
        $this->dbPrefix = _DB_PREFIX_;
        $this->db = Db::getInstance();
    }

    /**
     * Restore the dump to the actual database.
     */
    public function restore(): void
    {
        $this->checkDumpFile();

        $restoreCommand = $this->buildMySQLCommand([$this->databaseName]);
        $restoreCommand .= ' < ' . escapeshellarg($this->dumpFile) . ' 2>&1';
        $this->exec($restoreCommand);

        // Clean EntityManager cache
        Cache::clean('objectmodel_*');
    }

    /**
     * Restore a specific table in the database.
     *
     * @param string $table
     */
    public function restoreTable(string $table): void
    {
        $className = $this->getClassName($table);
        $this->cleanClassCache($className);
        $tableName = $this->dbPrefix . $table;
        $this->checkTableDumpFile($tableName);

        $dumpChecksum = file_get_contents($this->getTableChecksumPath($tableName));
        $checksum = $this->getTableChecksum($tableName);
        // Table was not modified, no need to restore
        if ($checksum === $dumpChecksum) {
            return;
        }

        $dumpFile = $this->getTableDumpPath($tableName);
        $restoreCommand = $this->buildMySQLCommand([$this->databaseName]);
        $restoreCommand .= ' < ' . escapeshellarg($dumpFile) . ' 2>&1';
        $this->exec($restoreCommand);
    }

    private function cleanClassCache(string $className): void
    {
        // Clean EntityManager cache
        Cache::clean(sprintf('objectmodel_%s_*', $className));
        // Clear static cache of the ObjectModel class related to the table
        $staticMethodCall = sprintf('%s::resetStaticCache', $className);
        if (is_callable($staticMethodCall)) {
            call_user_func($staticMethodCall);
        }
    }

    private function getClassName(string $table): string
    {
        if ($table === 'lang') {
            return Language::class;
        } elseif ($table === 'cms_category') {
            return CMSCategory::class;
        } elseif ($table === 'cms_role') {
            return CMSRole::class;
        } elseif ($table === 'product_attribute') {
            return Combination::class;
        } elseif ($table === 'connections') {
            return Connection::class;
        } elseif ($table === 'log') {
            return PrestaShopLogger::class;
        } elseif ($table === 'attribute') {
            return ProductAttribute::class;
        } elseif ($table === 'orders') {
            return Order::class;
        }

        return Inflector::getInflector()->classify($table);
    }

    /**
     * Wrapper to easily build mysql commands: sets password, port, user.
     *
     * @param array $arguments
     *
     * @return string
     */
    private function buildMySQLCommand(array $arguments = []): string
    {
        $parts = array_merge($this->getDefaultParameters('mysql'), array_map('escapeshellarg', $arguments));

        return implode(' ', $parts);
    }

    /**
     * Wrapper to easily build mysql commands: sets password, port, user.
     *
     * @param array $arguments
     *
     * @return string
     */
    private function buildMySQLCommandDumpFile(string $dumpfile, array $arguments = []): string
    {
        $parts = array_merge($this->getDefaultParameters('mysqldump'), ['-r', escapeshellarg($dumpfile)]);

        $parts = array_merge($parts, array_map('escapeshellarg', $arguments));

        return implode(' ', $parts);
    }

    /**
     * @return string[]
     */
    private function getDefaultParameters(string $executable): array
    {
        $parts = [
            $executable,
            '-u', escapeshellarg($this->user),
            '-P', escapeshellarg($this->port),
            '-h', escapeshellarg($this->host),
        ];

        if ($this->password) {
            $parts[] = '-p' . escapeshellarg($this->password);
        }

        return $parts;
    }

    /**
     * Like exec, but will raise an exception if the command failed.
     *
     * @param string $command
     *
     * @return array
     *
     * @throws Exception
     */
    private function exec($command): array
    {
        $output = [];
        $ret = 1;
        exec($command, $output, $ret);

        if ($ret !== 0) {
            throw new Exception(sprintf('Unable to exec command: `%s`, output : %s', $command, implode($output)));
        }

        return $output;
    }

    /**
     * The actual dump function.
     */
    private function dump(): void
    {
        $dumpCommand = $this->buildMySQLCommandDumpFile($this->dumpFile, [$this->databaseName, '--complete-insert']);
        $dumpCommand .= ' 2>&1';
        $this->exec($dumpCommand);
    }

    private function dumpAllTables(): void
    {
        $tables = $this->db->executeS('SHOW TABLES;');
        foreach ($tables as $table) {
            // $table is an array looking like this [Tables_in_database_name => 'ps_access']
            $this->dumpTable(reset($table));
        }
    }

    private function dumpTable(string $table): void
    {
        $tableDumpFile = $this->getTableDumpPath($table);
        $dumpCommand = $this->buildMySQLCommandDumpFile($tableDumpFile, [$this->databaseName, $table, '--complete-insert']);
        $dumpCommand .= ' 2>&1';
        $this->exec($dumpCommand);

        $checksum = $this->getTableChecksum($table);
        $checksumFile = $this->getTableChecksumPath($table);
        file_put_contents($checksumFile, $checksum);
    }

    private function getTableDumpPath(string $table): string
    {
        return sprintf(
            '%s/ps_dump_%s_%s_%s.sql',
            sys_get_temp_dir(),
            $this->databaseName,
            Version::VERSION,
            $table
        );
    }

    private function getTableChecksumPath(string $table): string
    {
        return sprintf(
            '%s/ps_dump_%s_%s_%s.md5',
            sys_get_temp_dir(),
            $this->databaseName,
            Version::VERSION,
            $table
        );
    }

    /**
     * Get checksum of the table to compare if the conent has been modified and needs to be restored. Since the checksum
     * doesn't take the auto increment index into consideration we fetch it manually and append it to the original
     * checksum, this allows to restore the index when needed as well.
     *
     * @param string $table
     *
     * @return string
     */
    private function getTableChecksum(string $table): string
    {
        $checksum = $this->db->executeS(sprintf('CHECKSUM TABLE %s;', $table));
        $checksum = $checksum[0]['Checksum'];

        // The content only is not enough we must make sure that the auto increment index is the same
        $autoIncrement = $this->db->executeS(sprintf(
            'SELECT AUTO_INCREMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = "%s" AND TABLE_NAME = "%s";',
            $this->databaseName,
            $table
        ));
        // Some tables have no auto increment (like relation tables for example)
        $autoIncrement = (int) ($autoIncrement[0]['AUTO_INCREMENT'] ?? 0);

        return $checksum . $autoIncrement;
    }

    private function checkDumpFile(): void
    {
        if (!file_exists($this->dumpFile)) {
            throw new Exception('You need to run \'composer create-test-db\' to create the initial test database');
        }
    }

    private function checkTableDumpFile(string $tableName): void
    {
        $dumpFile = $this->getTableDumpPath($tableName);
        if (!file_exists($dumpFile)) {
            throw new Exception(sprintf(
                'Cannot find dump for table %s, you need to run \'composer create-test-db\' to create the initial test database',
                $tableName
            ));
        }
    }

    /**
     * Make a database dump.
     */
    public static function create(): void
    {
        $dump = new static();

        $dump->dump();
    }

    /**
     * Make dump for each table in the database.
     */
    public static function dumpTables(): void
    {
        $dump = new static();

        $dump->dumpAllTables();
    }

    /**
     * Check that dump file exists
     *
     * @throws Exception
     */
    public static function checkDump(): void
    {
        $dump = new static();

        $dump->checkDumpFile();
    }

    /**
     * Restore a database dump.
     */
    public static function restoreDb(): void
    {
        $dump = new static();

        $dump->restore();
    }

    /**
     * Restore all tables (only modified tables are restored)
     */
    public static function restoreAllTables(): void
    {
        $dump = new static();

        $tables = $dump->db->executeS('SHOW TABLES;');
        foreach ($tables as $table) {
            // $table is an array looking like this [Tables_in_database_name => 'ps_access']
            $tableName = reset($table);
            $tableName = substr($tableName, strlen($dump->dbPrefix));
            $dump->restoreTable($tableName);
        }
    }

    /**
     * Restore a list of tables in the database
     *
     * @param array $tableNames
     */
    public static function restoreTables(array $tableNames): void
    {
        $dump = new static();

        foreach ($tableNames as $tableName) {
            $dump->restoreTable($tableName);
        }
    }

    /**
     * Restore a list of tables in the database which name match the regexp
     *
     * @param string $regexp
     */
    public static function restoreMatchingTables(string $regexp): void
    {
        $dump = new static();

        $tables = $dump->db->executeS('SHOW TABLES;');
        foreach ($tables as $table) {
            // $table is an array looking like this [Tables_in_database_name => 'ps_access']
            $tableName = reset($table);
            $tableName = substr($tableName, strlen($dump->dbPrefix));
            if (preg_match($regexp, $tableName)) {
                $dump->restoreTable($tableName);
            }
        }
    }
}
