<?php

namespace Drush\Commands\core;

use Drupal;
use Drush\Boot\DrupalBootLevels;
use Drush\Commands\DrushCommands;
use Drush\Drush;
use Drush\Sql\SqlBase;
use Drush\Utils\FsUtils;
use Exception;
use FilesystemIterator;
use Phar;
use PharData;
use RecursiveCallbackFilterIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Process\Process;
use Symfony\Component\Yaml\Yaml;
use Traversable;

class ArchiveDumpCommands extends DrushCommands
{
    private Filesystem $filesystem;
    private string $archiveDir;
    private string $drupalFilesDir;

    private const COMPONENT_CODE = 'code';

    private const COMPONENT_FILES = 'files';

    private const COMPONENT_DATABASE = 'database';
    private const SQL_DUMP_FILE_NAME = 'database.sql';

    private const ARCHIVES_DIR_NAME = 'archives';
    private const ARCHIVE_FILE_NAME = 'archive.tar';
    private const MANIFEST_FORMAT_VERSION = '1.0';
    private const MANIFEST_FILE_NAME = 'MANIFEST.yml';

    /**
     * Backup your code, files, and database into a single file.
     *
     * The following paths would be excluded from a code archive:
     *
     *  - .git
     *  - vendor
     *  - [docroot]/sites/@/settings.@.php
     *  - Drupal files directory
     *  - Composer packages installed paths (`composer info --path --format=json`)
     *
     * The following directories would be excluded from a file archive:
     *
     * - css
     * - js
     * - styles
     * - php
     *
     * @command archive:dump
     * @validate-php-extension Phar
     * @aliases ard
     *
     * @option destination The full path and filename in which the archive should be stored. If omitted, it will be saved to the drush-backups directory.
     * @option overwrite Overwrite destination file if exists.
     * @option code Archive codebase.
     * @option exclude-code-paths Comma-separated list of paths (or regular expressions matching paths) to exclude from the code archive.
     * @option files Archive Drupal files.
     * @option db Archive database SQL dump.
     * @option description Describe the archive contents.
     * @option tags Add tags to the archive manifest. Delimit several by commas.
     * @option generator The generator name to store in the MANIFEST.yml file. The default is "Drush archive-dump".
     * @option generatorversion The generator version number to store in the MANIFEST file. The default is Drush version.
     *
     * @usage drush archive:dump
     *   Create a site archive file in a temporary directory containing code, database and Drupal files.
     * @usage drush archive:dump --destination=/path/to/archive.tar.gz
     *   Create /path/to/archive.tar.gz file containing code, database and Drupal files.
     * @usage drush archive:dump --destination=/path/to/archive.tar.gz --overwrite
     *   Create (or overwrite if exists) /path/to/archive.tar.gz file containing code, database and Drupal files.
     * @usage drush archive:dump --code --destination=/path/to/archive.tar.gz
     *   Create /path/to/archive.tar.gz file containing the code only.
     * @usage drush archive:dump --exclude-code-paths=foo_bar.txt,web/sites/.+/settings.php --destination=/path/to/archive.tar.gz
     *   Create /path/to/archive.tar.gz file containing code, database and Drupal files but excluding foo_bar.txt file and settings.php files if found in web/sites/* subdirectories.
     * @usage drush archive:dump --files --destination=/path/to/archive.tar.gz
     *   Create /path/to/archive.tar.gz file containing the Drupal files only.
     * @usage drush archive:dump --database --destination=/path/to/archive.tar.gz
     *   Create /path/to/archive.tar.gz archive file containing the database dump only.
     *
     * @optionset_sql
     * @optionset_table_selection
     *
     * @bootstrap max configuration
     *
     * @param array $options
     *
     * @return string
     *
     * @throws \Exception
     */
    public function dump(array $options = [
        'code' => false,
        'files' => false,
        'db' => false,
        'destination' => InputOption::VALUE_REQUIRED,
        'overwrite' => false,
        'description' => InputOption::VALUE_REQUIRED,
        'tags' => InputOption::VALUE_REQUIRED,
        'generator' => InputOption::VALUE_REQUIRED,
        'generatorversion' => InputOption::VALUE_REQUIRED,
        'exclude-code-paths' => InputOption::VALUE_REQUIRED,
    ]): string
    {
        $this->prepareArchiveDir();

        if (!$options['code'] && !$options['files'] && !$options['db']) {
            $options['code'] = $options['files'] = $options['db'] = true;
        }

        $components = [];

        if ($options['code']) {
            $components[] = [
                'name' => self::COMPONENT_CODE,
                'path' => $this->getCodeComponentPath($options),
            ];
        }

        if ($options['files']) {
            $components[] = [
                'name' => self::COMPONENT_FILES,
                'path' => $this->getDrupalFilesComponentPath(),
            ];
        }

        if ($options['db']) {
            $components[] = [
                'name' => self::COMPONENT_DATABASE,
                'path' => $this->getDatabaseComponentPath($options),
            ];
        }

        return $this->createArchiveFile($components, $options);
    }

    /**
     * Creates a temporary directory for the archive.
     *
     * @throws \Exception
     */
    protected function prepareArchiveDir(): void
    {
        $this->filesystem = new Filesystem();
        $this->archiveDir = FsUtils::tmpDir(self::ARCHIVES_DIR_NAME);
    }

    /**
     * Creates the archive file and returns the absolute path.
     *
     * @param array $archiveComponents
     *   The list of components (files) to include into the archive file.
     * @param array $options
     *   The command options.
     *
     * @return string
     *   The full path to archive file.
     *
     * @throws \Exception
     */
    private function createArchiveFile(array $archiveComponents, array $options): string
    {
        if (!$archiveComponents) {
            throw new Exception(dt('Nothing to archive'));
        }

        $this->logger()->info(dt('Creating archive...'));
        $archivePath = Path::join(dirname($this->archiveDir), self::ARCHIVE_FILE_NAME);

        stream_wrapper_restore('phar');
        $archive = new PharData($archivePath);

        $this->createManifestFile($options);
        $archive->buildFromDirectory($this->archiveDir);

        $this->logger()->info(dt('Compressing archive...'));
        $this->filesystem->remove($archivePath . '.gz');
        $archive->compress(Phar::GZ);

        unset($archive);
        Phar::unlinkArchive($archivePath);
        $archivePath .= '.gz';

        if (!$options['destination']) {
            return $archivePath;
        }

        $options['destination'] = $this->destinationCleanup($options['destination']);

        if ($this->filesystem->exists($options['destination'])) {
            if (!$options['overwrite']) {
                throw new Exception(
                    dt('The destination file already exists. Use "--overwrite" option for overwriting an existing file.')
                );
            }

            $this->filesystem->remove($options['destination']);
        }

        $this->logger()->info(
            dt(
                'Moving archive file from !from to !to',
                ['!from' => $archivePath, '!to' => $options['destination']]
            )
        );
        $this->filesystem->rename($archivePath, $options['destination']);

        return $options['destination'];
    }

    /**
     * Creates the MANIFEST file.
     *
     * @param array $options
     *   The command options.
     *
     * @throws \Exception
     */
    private function createManifestFile(array $options): void
    {
        $this->logger()->info(dt('Creating !manifest file...', ['!manifest' => self::MANIFEST_FILE_NAME]));
        $manifest = [
            'datestamp' => time(),
            'formatversion' => self::MANIFEST_FORMAT_VERSION,
            'components' => [
                self::COMPONENT_CODE => $options['code'],
                self::COMPONENT_FILES => $options['files'],
                self::COMPONENT_DATABASE => $options['db'],
            ],
            'description' => $options['description'] ?? null,
            'tags' => $options['tags'] ?? null,
            'generator' => $options['generator'] ?? 'Drush archive:dump',
            'generatorversion' => $options['generatorversion'] ?? Drush::getVersion(),
        ];
        $manifestFilePath = Path::join($this->archiveDir, self::MANIFEST_FILE_NAME);
        file_put_contents(
            $manifestFilePath,
            Yaml::dump($manifest)
        );
    }

    /**
     * Returns TRUE if the site is a "web" docroot site.
     *
     * @return bool
     *
     * @throws \Exception
     */
    private function isWebRootSite(): bool
    {
        return $this->getComposerRoot() !== $this->getRoot();
    }

    /**
     * Returns site's docroot name.
     *
     * @return string
     *
     * @throws \Exception
     */
    private function getComposerRoot(): string
    {
        $bootstrapManager = Drush::bootstrapManager();
        $composerRoot = $bootstrapManager->getComposerRoot();
        if (!$composerRoot) {
            throw new Exception(dt('Path to Composer root is empty.'));
        }

        return $composerRoot;
    }

    /**
     * Returns site's docroot path.
     *
     * @return string
     *
     * @throws \Exception
     */
    private function getRoot(): string
    {
        $bootstrapManager = Drush::bootstrapManager();
        $root = $bootstrapManager->getRoot();
        if (!$root) {
            throw new Exception(dt('Path to Drupal docroot is empty.'));
        }

        return $root;
    }

    /**
     * Creates "code" archive component and returns the absolute path.
     *
     * @param array $options
     *  The command options.
     *
     * @return string
     *  The full path to the code archive component directory.
     *
     * @throws \Exception
     */
    private function getCodeComponentPath(array $options): string
    {
        $codePath = $this->getComposerRoot();
        $codeArchiveComponentPath = Path::join($this->archiveDir, self::COMPONENT_CODE);

        $this->logger()->info(
            dt(
                'Copying code files from !from_path to !to_path...',
                ['!from_path' => $codePath, '!to_path' => $codeArchiveComponentPath]
            )
        );

        $excludes = $options['exclude-code-paths']
            ? $this->getRegexpsForPaths(explode(',', $options['exclude-code-paths']))
            : [];

        $excludeDirs = [
            '.git',
            'vendor',
        ];

        $process = Process::fromShellCommandline(sprintf('composer info --path --format=json --working-dir=%s', $this->getComposerRoot()));
        $process->mustRun();
        $composerInfoRaw = $process->getOutput();
        $installedPackages = json_decode($composerInfoRaw, true)['installed'] ?? [];
        $installedPackagesPaths = array_column($installedPackages, 'path');
        $installedPackagesRelativePaths = array_map(
            fn($path) => ltrim(str_replace([$this->getComposerRoot()], '', $path), '/'),
            $installedPackagesPaths
        );
        $installedPackagesRelativePaths = array_unique(
            array_filter(
                $installedPackagesRelativePaths,
                fn($path) => '' !== $path && 0 !== strpos($path, 'vendor')
            )
        );
        $excludeDirs = array_merge($excludeDirs, $installedPackagesRelativePaths);

        if (Path::isBasePath($this->getComposerRoot(), $this->archiveDir)) {
            $excludeDirs[] = Path::makeRelative($this->archiveDir, $this->getComposerRoot());
        }

        $excludes = array_merge(
            $excludes,
            $this->getRegexpsForPaths(
                $excludeDirs
            ),
            $this->getDrupalExcludes()
        );

        $this->filesystem->mirror(
            $codePath,
            $codeArchiveComponentPath,
            $this->getFileIterator($codePath, $excludes)
        );

        return $codeArchiveComponentPath;
    }

    /**
     * Creates "Drupal files" archive component and returns the absolute path.
     *
     * @return string
     *  The full path to the Drupal files archive component directory.
     *
     * @throws \Exception
     */
    private function getDrupalFilesComponentPath(): string
    {
        $drupalFilesPath = $this->getDrupalFilesDir();
        $drupalFilesArchiveComponentPath = Path::join($this->archiveDir, self::COMPONENT_FILES);
        $this->logger()->info(
            dt(
                'Copying Drupal files from !from_path to !to_path...',
                ['!from_path' => $drupalFilesPath, '!to_path' => $drupalFilesArchiveComponentPath]
            )
        );

        $excludes = $this->getRegexpsForPaths([
            'css',
            'js',
            'styles',
            'php',
        ]);

        $this->filesystem->mirror(
            $drupalFilesPath,
            $drupalFilesArchiveComponentPath,
            $this->getFileIterator($drupalFilesPath, $excludes)
        );

        return $drupalFilesArchiveComponentPath;
    }

    /**
     * Returns the path to Drupal files directory.
     *
     * @return string
     *
     * @throws \Exception
     */
    private function getDrupalFilesDir(): string
    {
        if (isset($this->drupalFilesDir)) {
            return $this->drupalFilesDir;
        }

        Drush::bootstrapManager()->doBootstrap(DrupalBootLevels::FULL);
        $drupalFilesPath = Drupal::service('file_system')->realpath('public://');
        if (!$drupalFilesPath) {
            throw new Exception(dt('Path to Drupal files is empty.'));
        }

        return $this->drupalFilesDir = $drupalFilesPath;
    }

    /**
     * Returns file iterator.
     *
     * Excludes paths according to the list of excludes provides.
     * Validates for sensitive data present.
     *
     * @param string $path
     *   Directory.
     * @param array $excludes
     *   The list of file exclude rules (regular expressions).
     *
     * @return \Traversable
     */
    private function getFileIterator(string $path, array $excludes): Traversable
    {
        return new RecursiveIteratorIterator(
            new RecursiveCallbackFilterIterator(
                new RecursiveDirectoryIterator(
                    $path,
                    FilesystemIterator::SKIP_DOTS
                ),
                function ($file) use ($excludes, $path) {
                    $localFileName = str_replace($path, '', $file);
                    $localFileName = str_replace('\\', '/', $localFileName);
                    $localFileName = trim($localFileName, '\/');

                    foreach ($excludes as $exclude) {
                        if (preg_match($exclude, $localFileName)) {
                            $this->logger()->info(dt(
                                'Path excluded (!exclude): !path',
                                ['!exclude' => $exclude, '!path' => $localFileName]
                            ));

                            return false;
                        }
                    }

                    $this->validateSensitiveData($file, $localFileName);

                    return true;
                }
            )
        );
    }

    /**
     * Creates "database" archive component and returns the absolute path.
     *
     * @param array $options
     *   The command options.
     *
     * @return string
     *   The full path to the database archive component directory.
     *
     * @throws \Exception
     *
     * @see \Drush\Commands\sql\SqlCommands::dump()
     */
    private function getDatabaseComponentPath(array $options): string
    {
        $this->logger()->info(dt('Creating database SQL dump file...'));
        $databaseArchiveDir = Path::join($this->archiveDir, self::COMPONENT_DATABASE);
        $this->filesystem->mkdir($databaseArchiveDir);

        $options['result-file'] = Path::join($databaseArchiveDir, self::SQL_DUMP_FILE_NAME);
        $sql = SqlBase::create($options);
        if (false === $sql->dump()) {
            throw new Exception(dt('Unable to dump database. Rerun with --debug to see any error message.'));
        }

        return $databaseArchiveDir;
    }

    /**
     * Returns the list of regular expressions to match paths.
     *
     * @param array $paths
     *   The list of paths to match.
     *
     * @return array
     */
    private function getRegexpsForPaths(array $paths): array
    {
        return array_map(
            fn($path) => sprintf('#^%s$#', trim($path)),
            $paths
        );
    }

    /**
     * Returns docroot directory name with trailing escaped slash for a "web" docroot site for use in regular expressions, otherwise - empty string.
     *
     * @return string
     *
     * @throws \Exception
     */
    private function getDocrootRegexpPrefix(): string
    {
        return $this->isWebRootSite() ? basename($this->getRoot()) . '/' : '';
    }

    /**
     * Returns the list of regular expressions to match Drupal files paths and sites/@/settings.@.php files.
     *
     * @return array
     *
     * @throws \Exception
     */
    private function getDrupalExcludes(): array
    {
        $excludes = [
            '#^' . $this->getDocrootRegexpPrefix() . 'sites/.+/settings\..+\.php$#',
        ];

        $drupalFilesPath = $this->getDrupalFilesDir();
        $drupalFilesPathRelative = Path::makeRelative($drupalFilesPath, $this->getComposerRoot());
        $excludes[] = '#^' . $drupalFilesPathRelative . '$#';

        return $excludes;
    }

    /**
     * Validates files for sensitive data (database connection).
     *
     * Prevents creating a code archive containing a [docroot]/sites/@/settings.php file with database connection settings
     * defined.
     *
     * @param string $file
     *   The absolute path to the file.
     * @param string $localFileName
     *   The local (project-base) path to the file.
     *
     * @throws \Exception
     */
    private function validateSensitiveData(string $file, string $localFileName): void
    {
        $regexp = '#^' . $this->getDocrootRegexpPrefix() . 'sites/.*/settings\.php$#';
        if (!preg_match($regexp, $localFileName)) {
            return;
        }

        $settingsPhpFileContents = file_get_contents($file);
        $settingsWithoutComments = preg_replace('/\/\*(.*?)\*\/|(\/\/|#)(.*?)$/ms', '', $settingsPhpFileContents);
        $isDatabaseSettingsPresent = preg_match('/\$databases[^;]*=[^;]*(\[|(array[^;]*\())[^;]+(\]|\))[^;]*;/ms', $settingsWithoutComments);
        if ($isDatabaseSettingsPresent) {
            throw new Exception(
                dt(
                    'Found database connection settings in !path. It is risky to include them to the archive. Please move the database connection settings into a setting.*.php file or exclude them from the archive with "--exclude-code-paths=!path".',
                    ['!path' => $localFileName]
                )
            );
        }
    }

    /**
     * Provides basic verification/correction on destination option.
     *
     * @param string $destination
     *
     * @return void
     */
    private function destinationCleanup($destination)
    {
        // User input may be in the wrong format, this performs some basic
        // corrections. The correct format should include a .tar.gz.
        if (substr($destination, -7) !== ".tar.gz") {
            // If the user provided .tar but not .gz.
            if (substr($destination, -4) === ".tar") {
                return $destination . ".gz";
            }

            // If neither, the user provided a directory.
            if (substr($destination, -1) === "/") {
                return $destination . "archive.tar.gz";
            } else {
                return $destination . "/archive.tar.gz";
            }
        }
        return $destination;
    }
}
