<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Command;

use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Tenant\TenantContext;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * Creates an administrator from the command line.
 *
 * Like {@see TenantCreateCommand} this exists because the admin cannot
 * bootstrap itself: a fresh installation — or a freshly created tenant — has
 * nobody who can sign in and invite anyone else.
 *
 * On a multi-tenant installation an account belongs to exactly one tenant, so
 * the tenant has to be named: pass the installation-wide `--tenant=<slug>`
 * option, the same one every command gets from
 * {@see \Ferienpass\CoreBundle\EventListener\TenantConsoleListener}.
 */
#[AsCommand(name: 'ferienpass:user:create', description: 'Creates an administrator account')]
class UserCreateCommand extends Command
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly UserPasswordHasherInterface $passwordHasher,
        private readonly ValidatorInterface $validator,
        private readonly TenantContext $tenantContext,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('email', InputArgument::REQUIRED, 'The e-mail address to sign in with')
            ->addOption('firstname', null, InputOption::VALUE_REQUIRED, 'First name')
            ->addOption('lastname', null, InputOption::VALUE_REQUIRED, 'Last name')
            ->addOption('password', null, InputOption::VALUE_REQUIRED, 'The password. Prompted for when omitted, or generated and printed if there is nobody to prompt.')
            ->addOption('global-admin', null, InputOption::VALUE_NONE, 'Administer the installation, not one municipality: the tenant list, and stepping into any tenant to support it. Implies super admin.')
            ->setHelp(<<<'HELP'
                Creates an administrator of the tenant given by <info>--tenant</info>:

                  <info>bin/console ferienpass:user:create anna@nordhorn.de --tenant=nordhorn</info>

                The account is a super admin of that one municipality. Add
                <info>--global-admin</info> for someone who administers the installation itself.

                Passing <info>--password</info> puts the password in your shell history and in the
                process list; prefer being prompted, or let it be generated.
                HELP)
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $email = (string) $input->getArgument('email');

        // An account belongs to exactly one tenant, and e-mail is unique per
        // tenant rather than per installation — so with multi-tenancy on there
        // is no sensible tenant to guess, and guessing wrong would put the
        // administrator in another municipality's data.
        $tenant = $this->tenantContext->getTenant();
        if ($this->tenantContext->isEnabled() && null === $tenant) {
            $io->error('Multi-tenancy is enabled, so this account needs a tenant: pass --tenant=<slug>. Run "ferienpass:tenant:list" to see them.');

            return Command::FAILURE;
        }

        $criteria = ['email' => $email];
        if (null !== $tenant) {
            $criteria['tenant'] = $tenant;
        }

        if (null !== $this->entityManager->getRepository(User::class)->findOneBy($criteria)) {
            $io->error(null === $tenant
                ? \sprintf('A user "%s" already exists.', $email)
                : \sprintf('A user "%s" already exists in tenant "%s".', $email, $tenant->getSlug()));

            return Command::FAILURE;
        }

        [$password, $generated] = $this->password($input, $io);
        if (null === $password) {
            $io->error('No password given.');

            return Command::FAILURE;
        }

        $user = new User();
        $user->setEmail($email);
        $user->setFirstname($input->getOption('firstname'));
        $user->setLastname($input->getOption('lastname'));

        // ROLE_ADMIN is the account role that says "this is a member of staff",
        // and it has to be stored: AdminUserChecker asks the entity, which
        // reads the roles as written rather than as the hierarchy expands them.
        // ROLE_SUPER_ADMIN alone therefore authorises everything and still
        // cannot sign in — the password verifies, then checkPostAuth rejects
        // it, which reads as a wrong password.
        $user->setRoles(['ROLE_ADMIN']);
        $user->setSuperAdmin(true);

        if ($input->getOption('global-admin')) {
            $user->setGlobalAdmin(true);
        }

        if (null !== $tenant) {
            $user->setTenant($tenant);
        }

        $user->setPassword($this->passwordHasher->hashPassword($user, $password));

        $violations = $this->validator->validate($user);
        if ($violations->count() > 0) {
            $io->error(array_map(static fn ($v): string => \sprintf('%s: %s', $v->getPropertyPath(), $v->getMessage()), iterator_to_array($violations)));

            return Command::FAILURE;
        }

        $this->entityManager->persist($user);
        $this->entityManager->flush();

        $io->success(\sprintf('Created "%s"%s.', $email, null !== $tenant ? \sprintf(' in tenant "%s"', $tenant->getSlug()) : ''));

        // Say what was granted rather than leaving it to be discovered: these
        // roles reach ROLE_SUPER_ADMIN and below through the hierarchy.
        $io->table(['', ''], [
            ['Roles', implode(', ', $user->getRoles())],
            ['Signs in on', null !== $tenant ? implode(', ', $tenant->getAdminHosts() ?: $tenant->getFrontendHosts()) : 'this installation'],
        ]);

        if ($generated) {
            $io->warning(\sprintf('Generated password: %s', $password));
            $io->text('It is shown once and stored only as a hash. Change it after signing in.');
        }

        return Command::SUCCESS;
    }

    /**
     * @return array{0: string|null, 1: bool} the password, and whether it was generated
     */
    private function password(InputInterface $input, SymfonyStyle $io): array
    {
        $password = $input->getOption('password');
        if (null !== $password && '' !== $password) {
            return [(string) $password, false];
        }

        // Nobody to ask (CI, a provisioning script): generate one rather than
        // fail, and print it — an account nobody can sign in to is not useful.
        if (!$input->isInteractive()) {
            return [bin2hex(random_bytes(12)), true];
        }

        $ask = static function (string $label) use ($io): ?string {
            $question = new Question($label);
            $question->setHidden(true);
            $question->setHiddenFallback(false);

            return $io->askQuestion($question);
        };

        $password = $ask('Password');
        if (null === $password || '' === $password) {
            return [null, false];
        }

        if ($password !== $ask('Repeat password')) {
            $io->error('The passwords did not match.');

            return [null, false];
        }

        return [$password, false];
    }
}
