<?php

declare(strict_types=1);

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Backend\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use TYPO3\CMS\Core\Attribute\AsNonSchedulableCommand;

use TYPO3\CMS\Core\Configuration\ConfigurationManager;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Localization\Locales;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyAction;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyValidator;
use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\StringUtility;

/**
 * Create a new backend user
 */
#[AsCommand('backend:user:create', 'Create a backend user.')]
#[AsNonSchedulableCommand]
class CreateBackendUserCommand extends Command
{
    public function __construct(
        private readonly ConnectionPool $connectionPool,
        private readonly ConfigurationManager $configurationManager,
        private readonly LanguageServiceFactory $languageServiceFactory,
        private readonly Locales $locales,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addOption(
                'username',
                'u',
                InputOption::VALUE_REQUIRED,
                'The username of the backend user',
            )->addOption(
                'password',
                'p',
                InputOption::VALUE_REQUIRED,
                'The password of the backend user. See security note below.',
            )->addOption(
                'email',
                'e',
                InputOption::VALUE_REQUIRED,
                'The email address of the backend user',
                '',
            )
            ->addOption(
                'groups',
                'g',
                InputOption::VALUE_REQUIRED,
                'Assign given groups to the user'
            )
            ->addOption(
                'language',
                'l',
                InputOption::VALUE_REQUIRED,
                'The language for the user interface'
            )
            ->addOption(
                'admin',
                'a',
                InputOption::VALUE_NONE,
                'Create user with admin privileges'
            )->addOption(
                'maintainer',
                'm',
                InputOption::VALUE_NONE,
                'Create user with maintainer privileges',
            )->setHelp(
                <<<EOT

<fg=green>Create a backend user using environment variables</>

Example:
-------------------------------------------------
TYPO3_BE_USER_NAME=username \
TYPO3_BE_USER_EMAIL=admin@example.com \
TYPO3_BE_USER_GROUPS=<comma-separated-list-of-group-ids> \
TYPO3_BE_USER_LANGUAGE=de \
TYPO3_BE_USER_ADMIN=0 \
TYPO3_BE_USER_MAINTAINER=0 \
./bin/typo3 backend:user:create --no-interaction
-------------------------------------------------
<fg=yellow>
Variable "TYPO3_BE_USER_PASSWORD" and options "-p" or "--password" can be
used to provide a password. Using this can be a security risk since the password
may end up in shell history files. Prefer the interactive mode. Additionally,
writing a command to shell history can be suppressed by prefixing the command
with a space when using `bash` or `zsh`.
</>
EOT
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $input->setInteractive(!$input->getOption('no-interaction'));

        /** @var QuestionHelper $questionHelper */
        $questionHelper = $this->getHelper('question');
        $username = $this->getUsername($questionHelper, $input, $output);
        $password = $this->getPassword($questionHelper, $input, $output);
        $email = $this->getEmail($questionHelper, $input, $output) ?: '';
        $maintainer = $this->getMaintainer($questionHelper, $input, $output);
        $language = $this->getLanguage($questionHelper, $input, $output) ?: 'en';

        // If the user is 'maintainer' it is also required to set the 'admin' flag.
        if ($maintainer) {
            $admin = true;
        } else {
            $admin = $this->getAdmin($questionHelper, $input, $output);
        }

        // If 'admin' flag was set, this prompt is skipped.
        // Because this user does already have access to the entire system.
        if ($admin) {
            $groups = [];
        } else {
            $groups = $this->getGroups($questionHelper, $input, $output);
        }

        $this->createUser($username, $password, $email, $admin, $maintainer, $groups, $language);

        return Command::SUCCESS;
    }

    private function getUsername(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string
    {
        // Taking deleted users into account as we want the username to be unique.
        // So in case a user was deleted and will be restored, this could cause duplicated usernames.
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('be_users');
        $queryBuilder->getRestrictions()->removeAll();
        $usernames = $queryBuilder
            ->select('username')
            ->from('be_users')
            ->executeQuery()
            ->fetchFirstColumn();

        $usernameValidator = static function ($username) use ($usernames) {
            if (empty($username)) {
                throw new \RuntimeException(
                    'Backend username must not be empty.',
                    1669822315,
                );
            }

            if (in_array($username, $usernames, true)) {
                throw new \RuntimeException(
                    'The username "' . $username . '" is already taken. Please use another username.',
                    1670797516,
                );
            }

            return $username;
        };

        $usernameFromCli = $this->getFallbackValueEnvOrOption($input, 'username', 'TYPO3_BE_USER_NAME');
        if ($usernameFromCli === false && $input->isInteractive()) {
            $questionUsername = new Question('Enter the backend username of the new account: ');
            $questionUsername->setValidator($usernameValidator);

            return $questionHelper->ask($input, $output, $questionUsername);
        }

        return $usernameValidator($usernameFromCli);
    }

    private function getPassword(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string
    {
        $passwordValidator = function ($password) {
            $passwordValidationErrors = $this->getBackendUserPasswordValidationErrors((string)$password);
            if (!empty($passwordValidationErrors)) {
                throw new \RuntimeException(
                    'The given password is not secure enough!' . PHP_EOL
                    . ' * ' . implode(PHP_EOL . ' * ', $passwordValidationErrors),
                    1670267532,
                );
            }

            return $password;
        };

        $passwordFromCli = $this->getFallbackValueEnvOrOption($input, 'password', 'TYPO3_BE_USER_PASSWORD');

        // Force this question if no password set via cli.
        // Thus, the user will always be prompted for a password even --no-interaction is set.
        $currentlyInteractive = $input->isInteractive();
        $input->setInteractive(true);
        if ($passwordFromCli === false) {
            $questionPassword = new Question('Enter a password for the backend user: ');
            $questionPassword->setHidden(true);
            $questionPassword->setHiddenFallback(false);
            $questionPassword->setValidator($passwordValidator);

            return $questionHelper->ask($input, $output, $questionPassword);
        }
        $input->setInteractive($currentlyInteractive);

        return $passwordValidator($passwordFromCli);
    }

    private function getEmail(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string
    {
        $emailValidator = static function ($email) {
            if (!empty($email) && !GeneralUtility::validEmail($email)) {
                throw new \RuntimeException(
                    'The given email is not valid! Please try again.',
                    1669813635,
                );
            }

            return $email;
        };

        $emailFromCli = $this->getFallbackValueEnvOrOption($input, 'email', 'TYPO3_BE_USER_EMAIL');
        if ($emailFromCli === false && $input->isInteractive()) {
            $questionEmail = new Question('Enter the email for the backend user: ', '');
            $questionEmail->setValidator($emailValidator);

            return $questionHelper->ask($input, $output, $questionEmail);
        }

        return (string)$emailValidator($emailFromCli);
    }

    private function getGroups(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): array
    {
        $queryBuilder = $this->connectionPool->getConnectionForTable('be_groups');
        $groupsList = $queryBuilder->select(['uid', 'title'], 'be_groups')->fetchAllAssociative();

        $groupChoices = [];
        foreach ($groupsList as $group) {
            $groupChoices[$group['uid']] = $group['title'];
        }

        $groupValidator = static function ($groupList) use ($groupChoices) {
            $groups = GeneralUtility::intExplode(',', $groupList ?: '');
            foreach ($groups as $group) {
                if (!empty($group) && !isset($groupChoices[$group])) {
                    throw new \RuntimeException(
                        'The given group uid "' . $group . '"  does not exist.',
                        1670812929,
                    );
                }
            }

            return $groups;
        };

        $groupsFromCli = $this->getFallbackValueEnvOrOption($input, 'groups', 'TYPO3_BE_USER_GROUPS');
        if ($groupsFromCli === false && $input->isInteractive()) {
            if (empty($groupChoices)) {
                return [];
            }

            $questionGroups = new ChoiceQuestion('Select groups the newly created backend user should be assigned to (use comma-separated list for multiple groups): ', $groupChoices);
            $questionGroups->setMultiselect(true);
            $questionGroups->setValidator($groupValidator);
            // Ensure keys are selected and not the values
            $questionGroups->setAutocompleterValues(array_keys($groupChoices));
            return $questionHelper->ask($input, $output, $questionGroups);
        }

        return $groupValidator($groupsFromCli);
    }

    private function getMaintainer(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): bool
    {
        $maintainerFromCli = $this->getFallbackValueEnvOrOption($input, 'maintainer', 'TYPO3_BE_USER_MAINTAINER');
        if ($maintainerFromCli === false && $input->isInteractive()) {
            $questionMaintainer = new ConfirmationQuestion('Create user with maintainer privileges [y/n default: n] ? ', false);
            return (bool)$questionHelper->ask($input, $output, $questionMaintainer);
        }

        return (bool)$maintainerFromCli;
    }

    private function getAdmin(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): bool
    {
        $adminFromCli = $this->getFallbackValueEnvOrOption($input, 'admin', 'TYPO3_BE_USER_ADMIN');
        if ($adminFromCli === false && $input->isInteractive()) {
            $questionAdmin = new ConfirmationQuestion('Create user with admin privileges [y/n default: n] ? ', false);
            return (bool)$questionHelper->ask($input, $output, $questionAdmin);
        }

        return (bool)$adminFromCli;
    }

    private function getLanguage(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string
    {
        $languagesList = $this->locales->getLanguages();

        $languageValidator = static function ($language) use ($languagesList) {
            if (!empty($language) && !isset($languagesList[$language])) {
                throw new \RuntimeException(
                    'The given language "' . $language . '"  is not supported.',
                    1769429507
                );
            }

            return $language;
        };

        $languageFromCli = $this->getFallbackValueEnvOrOption($input, 'language', 'TYPO3_BE_USER_LANGUAGE');
        if ($languageFromCli === false && $input->isInteractive()) {
            $questionLanguage = new Question('Enter the language for the user interface [eg: de/fr/it/...]: ', '');
            $questionLanguage->setValidator($languageValidator);
            $questionLanguage->setAutocompleterValues(array_keys($languagesList));

            return $questionHelper->ask($input, $output, $questionLanguage);
        }

        return (string)$languageValidator($languageFromCli);
    }

    /**
     * Get a value from
     * 1. environment variable
     * 2. cli option
     */
    private function getFallbackValueEnvOrOption(InputInterface $input, string $option, string $envVar): string|bool
    {
        $optionShortcut = $this->getDefinition()->getOption($option)->getShortcut();
        $parameterOptions = ['--' . $option];
        if ($optionShortcut !== null) {
            $parameterOptions[] = '-' . $optionShortcut;
        }
        return $input->hasParameterOption($parameterOptions) ? $input->getOption($option) : getenv($envVar);
    }

    private function getBackendUserPasswordValidationErrors(string $password): array
    {
        $GLOBALS['LANG'] = $this->languageServiceFactory->create('en');
        $passwordPolicy = $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy'] ?? 'default';
        $passwordPolicyValidator = new PasswordPolicyValidator(
            PasswordPolicyAction::NEW_USER_PASSWORD,
            is_string($passwordPolicy) ? $passwordPolicy : ''
        );
        $contextData = new ContextData();
        $passwordPolicyValidator->isValidPassword($password, $contextData);

        return $passwordPolicyValidator->getValidationErrors();
    }

    /**
     * Create a backend user.
     * similar to "\TYPO3\CMS\Install\Service\SetupService::createUser()",
     * but accepts admin/maintainer flag and groups
     */
    private function createUser(string $username, string $password, string $email = '', bool $admin = false, bool $maintainer = false, array $groups = [], string $language = 'en'): void
    {
        // Initialize backend user authentication to ensure the new backend user can be created with proper permissions
        Bootstrap::initializeBackendAuthentication();

        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
        $backendUserId = StringUtility::getUniqueId('NEW');
        $data = [
            'be_users' => [
                $backendUserId => [
                    'pid' => 0,
                    'username' => $username,
                    'password' => $password,
                    'email' => $email,
                    'admin' => $admin ? 1 : 0,
                    'usergroup' => $groups,
                    'disable' => 0,
                    'lang' => $language,
                ],
            ],
        ];

        $dataHandler->start($data, []);
        $dataHandler->process_datamap();

        $backendUserId = $dataHandler->substNEWwithIDs[$backendUserId] ?? null;
        if ($maintainer && $backendUserId) {
            $maintainerIds = $this->configurationManager->getConfigurationValueByPath('SYS/systemMaintainers') ?? [];
            sort($maintainerIds);
            $maintainerIds[] = $backendUserId;
            $this->configurationManager->setLocalConfigurationValuesByPathValuePairs([
                'SYS/systemMaintainers' => array_unique($maintainerIds),
            ]);
        }
    }
}
