<?php

/*
 * This file is part of the Symfony MakerBundle package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Bundle\MakerBundle\Maker;

use Doctrine\Common\Annotations\Annotation;
use Doctrine\ORM\EntityManagerInterface;
use PhpParser\Builder\Param;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
use Symfony\Bundle\MakerBundle\Doctrine\EntityClassGenerator;
use Symfony\Bundle\MakerBundle\Doctrine\ORMDependencyBuilder;
use Symfony\Bundle\MakerBundle\Doctrine\RelationManyToOne;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Security\InteractiveSecurityHelper;
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Translation\Translator;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Yaml\Yaml;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
use SymfonyCasts\Bundle\ResetPassword\Persistence\Repository\ResetPasswordRequestRepositoryTrait;
use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelper;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
use SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle;

/**
 * @author Romaric Drigon <romaric.drigon@gmail.com>
 * @author Jesse Rushlow  <jr@rushlow.dev>
 * @author Ryan Weaver    <ryan@symfonycasts.com>
 * @author Antoine Michelet <jean.marcel.michelet@gmail.com>
 *
 * @internal
 * @final
 */
class MakeResetPassword extends AbstractMaker
{
    private $fileManager;
    private $doctrineHelper;
    private $entityClassGenerator;

    private $fromEmailAddress;
    private $fromEmailName;
    private $controllerResetSuccessRedirect;
    private $userClass;
    private $emailPropertyName;
    private $emailGetterMethodName;
    private $passwordSetterMethodName;

    public function __construct(FileManager $fileManager, DoctrineHelper $doctrineHelper, EntityClassGenerator $entityClassGenerator)
    {
        $this->fileManager = $fileManager;
        $this->doctrineHelper = $doctrineHelper;
        $this->entityClassGenerator = $entityClassGenerator;
    }

    public static function getCommandName(): string
    {
        return 'make:reset-password';
    }

    public static function getCommandDescription(): string
    {
        return 'Create controller, entity, and repositories for use with symfonycasts/reset-password-bundle';
    }

    public function configureCommand(Command $command, InputConfiguration $inputConfig): void
    {
        $command
            ->setHelp(file_get_contents(__DIR__.'/../Resources/help/MakeResetPassword.txt'))
        ;
    }

    public function configureDependencies(DependencyBuilder $dependencies): void
    {
        $dependencies->addClassDependency(SymfonyCastsResetPasswordBundle::class, 'symfonycasts/reset-password-bundle');
        $dependencies->addClassDependency(MailerInterface::class, 'symfony/mailer');
        $dependencies->addClassDependency(Form::class, 'symfony/form');
        $dependencies->addClassDependency(Validation::class, 'symfony/validator');
        $dependencies->addClassDependency(SecurityBundle::class, 'security-bundle');
        $dependencies->addClassDependency(AppVariable::class, 'twig');

        ORMDependencyBuilder::buildDependencies($dependencies);

        $dependencies->addClassDependency(Annotation::class, 'annotations');

        // reset-password-bundle 1.6 includes the ability to generate a fake token.
        // we need to check that version 1.6 is installed
        if (class_exists(ResetPasswordHelper::class) && !method_exists(ResetPasswordHelper::class, 'generateFakeResetToken')) {
            throw new RuntimeCommandException('Please run "composer upgrade symfonycasts/reset-password-bundle". Version 1.6 or greater of this bundle is required.');
        }
    }

    public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
    {
        $io->title('Let\'s make a password reset feature!');

        $interactiveSecurityHelper = new InteractiveSecurityHelper();

        if (!$this->fileManager->fileExists($path = 'config/packages/security.yaml')) {
            throw new RuntimeCommandException('The file "config/packages/security.yaml" does not exist. This command needs that file to accurately build the reset password form.');
        }

        $manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($path));
        $securityData = $manipulator->getData();
        $providersData = $securityData['security']['providers'] ?? [];

        $this->userClass = $interactiveSecurityHelper->guessUserClass(
            $io,
            $providersData,
            'What is the User entity that should be used with the "forgotten password" feature? (e.g. <fg=yellow>App\\Entity\\User</>)'
        );

        $this->emailPropertyName = $interactiveSecurityHelper->guessEmailField($io, $this->userClass);
        $this->emailGetterMethodName = $interactiveSecurityHelper->guessEmailGetter($io, $this->userClass, $this->emailPropertyName);
        $this->passwordSetterMethodName = $interactiveSecurityHelper->guessPasswordSetter($io, $this->userClass);

        $io->text(sprintf('Implementing reset password for <info>%s</info>', $this->userClass));

        $io->section('- ResetPasswordController -');
        $io->text('A named route is used for redirecting after a successful reset. Even a route that does not exist yet can be used here.');

        $this->controllerResetSuccessRedirect = $io->ask(
            'What route should users be redirected to after their password has been successfully reset?',
            'app_home',
            [Validator::class, 'notBlank']
        );

        $io->section('- Email -');
        $emailText[] = 'These are used to generate the email code. Don\'t worry, you can change them in the code later!';
        $io->text($emailText);

        $this->fromEmailAddress = $io->ask(
            'What email address will be used to send reset confirmations? e.g. mailer@your-domain.com',
            null,
            [Validator::class, 'validateEmailAddress']
        );

        $this->fromEmailName = $io->ask(
            'What "name" should be associated with that email address? e.g. "Acme Mail Bot"',
            null,
            [Validator::class, 'notBlank']
        );
    }

    public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
    {
        $userClassNameDetails = $generator->createClassNameDetails(
            '\\'.$this->userClass,
            'Entity\\'
        );

        $controllerClassNameDetails = $generator->createClassNameDetails(
            'ResetPasswordController',
            'Controller\\'
        );

        $requestClassNameDetails = $generator->createClassNameDetails(
            'ResetPasswordRequest',
            'Entity\\'
        );

        $repositoryClassNameDetails = $generator->createClassNameDetails(
            'ResetPasswordRequestRepository',
            'Repository\\'
        );

        $requestFormTypeClassNameDetails = $generator->createClassNameDetails(
            'ResetPasswordRequestFormType',
            'Form\\'
        );

        $changePasswordFormTypeClassNameDetails = $generator->createClassNameDetails(
            'ChangePasswordFormType',
            'Form\\'
        );

        /*
         * @legacy Conditional can be removed when MakerBundle no longer
         *         supports Symfony < 5.2
         */
        $passwordHasher = UserPasswordEncoderInterface::class;

        if (interface_exists(UserPasswordHasherInterface::class)) {
            $passwordHasher = UserPasswordHasherInterface::class;
        }

        $useStatements = new UseStatementGenerator([
            AbstractController::class,
            $userClassNameDetails->getFullName(),
            $changePasswordFormTypeClassNameDetails->getFullName(),
            $requestFormTypeClassNameDetails->getFullName(),
            TemplatedEmail::class,
            RedirectResponse::class,
            Request::class,
            Response::class,
            MailerInterface::class,
            Address::class,
            Route::class,
            ResetPasswordControllerTrait::class,
            ResetPasswordExceptionInterface::class,
            ResetPasswordHelperInterface::class,
            $passwordHasher,
            EntityManagerInterface::class,
        ]);

        // Namespace for ResetPasswordExceptionInterface was imported above
        $problemValidateMessageOrConstant = \defined('SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE')
            ? 'ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE'
            : "'There was a problem validating your password reset request'";
        $problemHandleMessageOrConstant = \defined('SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE')
            ? 'ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE'
            : "'There was a problem handling your password reset request'";

        if ($isTranslatorAvailable = class_exists(Translator::class)) {
            $useStatements->addUseStatement(TranslatorInterface::class);
        }

        $generator->generateController(
            $controllerClassNameDetails->getFullName(),
            'resetPassword/ResetPasswordController.tpl.php',
            [
                'use_statements' => $useStatements,
                'user_class_name' => $userClassNameDetails->getShortName(),
                'request_form_type_class_name' => $requestFormTypeClassNameDetails->getShortName(),
                'reset_form_type_class_name' => $changePasswordFormTypeClassNameDetails->getShortName(),
                'password_setter' => $this->passwordSetterMethodName,
                'success_redirect_route' => $this->controllerResetSuccessRedirect,
                'from_email' => $this->fromEmailAddress,
                'from_email_name' => $this->fromEmailName,
                'email_getter' => $this->emailGetterMethodName,
                'email_field' => $this->emailPropertyName,
                'password_hasher_class_details' => ($passwordClassDetails = $generator->createClassNameDetails($passwordHasher, '\\')),
                'password_hasher_variable_name' => str_replace('Interface', '', sprintf('$%s', lcfirst($passwordClassDetails->getShortName()))), // @legacy see passwordHasher conditional above
                'use_password_hasher' => UserPasswordHasherInterface::class === $passwordHasher, // @legacy see passwordHasher conditional above
                'problem_validate_message_or_constant' => $problemValidateMessageOrConstant,
                'problem_handle_message_or_constant' => $problemHandleMessageOrConstant,
                'translator_available' => $isTranslatorAvailable,
            ]
        );

        $this->generateRequestEntity($generator, $requestClassNameDetails, $repositoryClassNameDetails);

        $this->setBundleConfig($io, $generator, $repositoryClassNameDetails->getFullName());

        $useStatements = new UseStatementGenerator([
            AbstractType::class,
            EmailType::class,
            FormBuilderInterface::class,
            OptionsResolver::class,
            NotBlank::class,
        ]);

        $generator->generateClass(
            $requestFormTypeClassNameDetails->getFullName(),
            'resetPassword/ResetPasswordRequestFormType.tpl.php',
            [
                'use_statements' => $useStatements,
                'email_field' => $this->emailPropertyName,
            ]
        );

        $useStatements = new UseStatementGenerator([
            AbstractType::class,
            PasswordType::class,
            RepeatedType::class,
            FormBuilderInterface::class,
            OptionsResolver::class,
            Length::class,
            NotBlank::class,
        ]);

        $generator->generateClass(
            $changePasswordFormTypeClassNameDetails->getFullName(),
            'resetPassword/ChangePasswordFormType.tpl.php',
            ['use_statements' => $useStatements]
        );

        $generator->generateTemplate(
            'reset_password/check_email.html.twig',
            'resetPassword/twig_check_email.tpl.php'
        );

        $generator->generateTemplate(
            'reset_password/email.html.twig',
            'resetPassword/twig_email.tpl.php'
        );

        $generator->generateTemplate(
            'reset_password/request.html.twig',
            'resetPassword/twig_request.tpl.php',
            [
                'email_field' => $this->emailPropertyName,
            ]
        );

        $generator->generateTemplate(
            'reset_password/reset.html.twig',
            'resetPassword/twig_reset.tpl.php'
        );

        $generator->writeChanges();

        $this->writeSuccessMessage($io);
        $this->successMessage($input, $io, $requestClassNameDetails->getFullName());
    }

    private function setBundleConfig(ConsoleStyle $io, Generator $generator, string $repositoryClassFullName): void
    {
        $configFileExists = $this->fileManager->fileExists($path = 'config/packages/reset_password.yaml');

        /*
         * reset_password.yaml does not exist, we assume flex was present when
         * the bundle was installed & a customized configuration is in use.
         * Remind the developer to set the repository class accordingly.
         */
        if (!$configFileExists) {
            $io->text(sprintf('We can\'t find %s. That\'s ok, you probably have a customized configuration.', $path));
            $io->text('Just remember to set the <fg=yellow>request_password_repository</> in your configuration.');
            $io->newLine();

            return;
        }

        $manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($path));
        $data = $manipulator->getData();

        $symfonyCastsKey = 'symfonycasts_reset_password';

        /*
         * reset_password.yaml exists, and was probably created by flex;
         * Let's replace it with a "clean" file.
         */
        if (1 >= \count($data[$symfonyCastsKey])) {
            $yaml = [
                $symfonyCastsKey => [
                    'request_password_repository' => $repositoryClassFullName,
                ],
            ];

            $generator->dumpFile($path, Yaml::dump($yaml));

            return;
        }

        /*
         * reset_password.yaml exists and appears to have been customized
         * before running make:reset-password. Let's just change the repository
         * value and preserve everything else.
         */
        $data[$symfonyCastsKey]['request_password_repository'] = $repositoryClassFullName;

        $manipulator->setData($data);

        $generator->dumpFile($path, $manipulator->getContents());
    }

    private function successMessage(InputInterface $input, ConsoleStyle $io, string $requestClassName): void
    {
        $closing[] = 'Next:';
        $closing[] = sprintf('  1) Run <fg=yellow>"php bin/console make:migration"</> to generate a migration for the new <fg=yellow>"%s"</> entity.', $requestClassName);
        $closing[] = '  2) Review forms in <fg=yellow>"src/Form"</> to customize validation and labels.';
        $closing[] = '  3) Review and customize the templates in <fg=yellow>`templates/reset_password`</>.';
        $closing[] = '  4) Make sure your <fg=yellow>MAILER_DSN</> env var has the correct settings.';
        $closing[] = '  5) Create a "forgot your password link" to the <fg=yellow>app_forgot_password_request</> route on your login form.';

        $io->text($closing);
        $io->newLine();
        $io->text('Then open your browser, go to "/reset-password" and enjoy!');
        $io->newLine();
    }

    private function generateRequestEntity(Generator $generator, ClassNameDetails $requestClassNameDetails, ClassNameDetails $repositoryClassNameDetails): void
    {
        $requestEntityPath = $this->entityClassGenerator->generateEntityClass($requestClassNameDetails, false, false, false);

        $generator->writeChanges();

        $useAttributesForDoctrineMapping = $this->doctrineHelper->isDoctrineSupportingAttributes() && $this->doctrineHelper->doesClassUsesAttributes($requestClassNameDetails->getFullName());

        $manipulator = new ClassSourceManipulator(
            $this->fileManager->getFileContents($requestEntityPath),
            false,
            !$useAttributesForDoctrineMapping,
            true,
            $useAttributesForDoctrineMapping
        );

        $manipulator->addInterface(ResetPasswordRequestInterface::class);

        $manipulator->addTrait(ResetPasswordRequestTrait::class);

        $manipulator->addConstructor([
            (new Param('user'))->setType('object')->getNode(),
            (new Param('expiresAt'))->setType('\DateTimeInterface')->getNode(),
            (new Param('selector'))->setType('string')->getNode(),
            (new Param('hashedToken'))->setType('string')->getNode(),
        ], <<<'CODE'
<?php
$this->user = $user;
$this->initialize($expiresAt, $selector, $hashedToken);
CODE
        );

        $manipulator->addManyToOneRelation((new RelationManyToOne())
            ->setPropertyName('user')
            ->setTargetClassName($this->userClass)
            ->setMapInverseRelation(false)
            ->setCustomReturnType('object', false)
            ->avoidSetter()
        );

        $this->fileManager->dumpFile($requestEntityPath, $manipulator->getSourceCode());

        $this->entityClassGenerator->generateRepositoryClass(
            $repositoryClassNameDetails->getFullName(),
            $requestClassNameDetails->getFullName(),
            false,
            false
        );

        $generator->writeChanges();

        $pathRequestRepository = $this->fileManager->getRelativePathForFutureClass(
            $repositoryClassNameDetails->getFullName()
        );

        $manipulator = new ClassSourceManipulator(
            $this->fileManager->getFileContents($pathRequestRepository)
        );

        $manipulator->addInterface(ResetPasswordRequestRepositoryInterface::class);

        $manipulator->addTrait(ResetPasswordRequestRepositoryTrait::class);

        $methodBuilder = $manipulator->createMethodBuilder('createResetPasswordRequest', ResetPasswordRequestInterface::class, false);

        $manipulator->addMethodBuilder($methodBuilder, [
            (new Param('user'))->setType('object')->getNode(),
            (new Param('expiresAt'))->setType('\DateTimeInterface')->getNode(),
            (new Param('selector'))->setType('string')->getNode(),
            (new Param('hashedToken'))->setType('string')->getNode(),
        ], <<<'CODE'
<?php
return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);
CODE
        );

        $this->fileManager->dumpFile($pathRequestRepository, $manipulator->getSourceCode());
    }
}
