<?php

/**
 * @package     Joomla.Administrator
 * @subpackage  com_users
 *
 * @copyright   (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\Component\Users\Administrator\Controller;

use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Event\MultiFactor\NotifyActionLog;
use Joomla\CMS\Event\MultiFactor\SaveSetup;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController as BaseControllerAlias;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryAwareInterface;
use Joomla\CMS\User\UserFactoryAwareTrait;
use Joomla\CMS\User\UserHelper;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
use Joomla\Component\Users\Administrator\Model\MethodModel;
use Joomla\Component\Users\Administrator\Table\MfaTable;
use Joomla\Input\Input;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Multi-factor Authentication method controller
 *
 * @since 4.2.0
 */
class MethodController extends BaseControllerAlias implements UserFactoryAwareInterface
{
    use UserFactoryAwareTrait;

    /**
     * Public constructor
     *
     * @param   array                 $config   Plugin configuration
     * @param   ?MVCFactoryInterface  $factory  MVC Factory for the com_users component
     * @param   ?CMSApplication       $app      CMS application object
     * @param   ?Input                $input    Joomla CMS input object
     *
     * @since 4.2.0
     */
    public function __construct(array $config = [], ?MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null)
    {
        // We have to tell Joomla what is the name of the view, otherwise it defaults to the name of the *component*.
        $config['default_view'] = 'method';
        $config['default_task'] = 'add';

        parent::__construct($config, $factory, $app, $input);
    }

    /**
     * Execute a task by triggering a Method in the derived class.
     *
     * @param   string  $task    The task to perform. If no matching task is found, the '__default' task is executed, if
     *                           defined.
     *
     * @return  mixed   The value returned by the called Method.
     *
     * @throws  \Exception
     * @since   4.2.0
     */
    public function execute($task)
    {
        if (empty($task) || $task === 'display') {
            $task = 'add';
        }

        return parent::execute($task);
    }

    /**
     * Add a new MFA Method
     *
     * @param   boolean        $cachable   Ignored. This page is never cached.
     * @param   boolean|array  $urlparams  Ignored. This page is never cached.
     *
     * @return  void
     * @throws  \Exception
     * @since   4.2.0
     */
    public function add($cachable = false, $urlparams = []): void
    {
        $this->assertLoggedInUser();

        // Make sure I am allowed to edit the specified user
        $userId = $this->input->getInt('user_id', null);
        $user   = $this->getUserFactory()->loadUserById($userId);

        $this->assertCanEdit($user);

        // Also make sure the Method really does exist
        $method = $this->input->getCmd('method');
        $this->assertMethodExists($method);

        /** @var MethodModel $model */
        $model = $this->getModel('Method');
        $model->setState('method', $method);

        // Pass the return URL to the view
        $returnURL  = $this->input->getBase64('returnurl');
        $viewLayout = $this->input->get('layout', 'default', 'string');
        $view       = $this->getView('Method', 'html');
        $view->setLayout($viewLayout);
        $view->returnURL = $returnURL;
        $view->user      = $user;
        $view->document  = $this->app->getDocument();

        $view->setModel($model, true);

        $event = new NotifyActionLog('onComUsersControllerMethodBeforeAdd', [$user, $method]);
        $this->app->getDispatcher()->dispatch($event->getName(), $event);

        $view->display();
    }

    /**
     * Edit an existing MFA Method
     *
     * @param   boolean        $cachable   Ignored. This page is never cached.
     * @param   boolean|array  $urlparams  Ignored. This page is never cached.
     *
     * @return  void
     * @throws  \Exception
     * @since   4.2.0
     */
    public function edit($cachable = false, $urlparams = []): void
    {
        $this->assertLoggedInUser();

        // Make sure I am allowed to edit the specified user
        $userId = $this->input->getInt('user_id', null);
        $user   = $this->getUserFactory()->loadUserById($userId);

        $this->assertCanEdit($user);

        // Also make sure the Method really does exist
        $id     = $this->input->getInt('id');
        $record = $this->assertValidRecordId($id, $user);

        if ($id <= 0) {
            throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
        }

        /** @var MethodModel $model */
        $model = $this->getModel('Method');
        $model->setState('id', $id);

        // Pass the return URL to the view
        $returnURL  = $this->input->getBase64('returnurl');
        $viewLayout = $this->input->get('layout', 'default', 'string');
        $view       = $this->getView('Method', 'html');
        $view->setLayout($viewLayout);
        $view->returnURL = $returnURL;
        $view->user      = $user;
        $view->document  = $this->app->getDocument();

        $view->setModel($model, true);

        $event = new NotifyActionLog('onComUsersControllerMethodBeforeEdit', [$id, $user]);
        $this->app->getDispatcher()->dispatch($event->getName(), $event);

        $view->display();
    }

    /**
     * Regenerate backup codes
     *
     * @param   boolean        $cachable   Ignored. This page is never cached.
     * @param   boolean|array  $urlparams  Ignored. This page is never cached.
     *
     * @return  void
     * @throws \Exception
     * @since   4.2.0
     */
    public function regenerateBackupCodes($cachable = false, $urlparams = []): void
    {
        $this->assertLoggedInUser();

        $this->checkToken($this->input->getMethod());

        // Make sure I am allowed to edit the specified user
        $userId = $this->input->getInt('user_id', null);
        $user   = $this->getUserFactory()->loadUserById($userId);
        $this->assertCanEdit($user);

        /** @var BackupcodesModel $model */
        $model = $this->getModel('Backupcodes');
        $model->regenerateBackupCodes($user);

        $backupCodesRecord = $model->getBackupCodesRecord($user);

        // Redirect
        $redirectUrl = 'index.php?option=com_users&task=method.edit&user_id=' . $userId . '&id=' . $backupCodesRecord->id;
        $returnURL   = $this->input->getBase64('returnurl');

        if (!empty($returnURL) && Uri::isInternal(base64_decode($returnURL))) {
            $redirectUrl .= '&returnurl=' . $returnURL;
        }

        $this->setRedirect(Route::_($redirectUrl, false));

        $event = new NotifyActionLog('onComUsersControllerMethodAfterRegenerateBackupCodes');
        $this->app->getDispatcher()->dispatch($event->getName(), $event);
    }

    /**
     * Delete an existing MFA Method
     *
     * @param   boolean        $cachable   Ignored. This page is never cached.
     * @param   boolean|array  $urlparams  Ignored. This page is never cached.
     *
     * @return  void
     * @since   4.2.0
     */
    public function delete($cachable = false, $urlparams = []): void
    {
        $this->assertLoggedInUser();

        $this->checkToken($this->input->getMethod());

        // Make sure I am allowed to edit the specified user
        $userId  = $this->input->getInt('user_id', null);
        $user    = $this->getUserFactory()->loadUserById($userId);
        $this->assertCanDelete($user);

        // Also make sure the Method really does exist
        $id     = $this->input->getInt('id');
        $record = $this->assertValidRecordId($id, $user);

        if ($id <= 0) {
            throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
        }

        $type    = null;
        $message = null;

        $event = new NotifyActionLog('onComUsersControllerMethodBeforeDelete', [$id, $user]);
        $this->app->getDispatcher()->dispatch($event->getName(), $event);

        try {
            $record->delete();
        } catch (\Exception $e) {
            $message = $e->getMessage();
            $type    = 'error';
        }

        // Redirect
        $url       = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $userId, false);
        $returnURL = $this->input->getBase64('returnurl');

        if (!empty($returnURL) && Uri::isInternal(base64_decode($returnURL))) {
            $url = base64_decode($returnURL);
        }

        $this->setRedirect($url, $message, $type);
    }

    /**
     * Save the MFA Method
     *
     * @param   boolean        $cachable   Ignored. This page is never cached.
     * @param   boolean|array  $urlparams  Ignored. This page is never cached.
     *
     * @return  void
     * @since   4.2.0
     */
    public function save($cachable = false, $urlparams = []): void
    {
        $this->assertLoggedInUser();

        $this->checkToken($this->input->getMethod());

        // Make sure I am allowed to edit the specified user
        $userId = $this->input->getInt('user_id', null);
        $user   = $this->getUserFactory()->loadUserById($userId);
        $this->assertCanEdit($user);

        // Redirect
        $url       = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $userId, false);
        $returnURL = $this->input->getBase64('returnurl');

        if (!empty($returnURL) && Uri::isInternal(base64_decode($returnURL))) {
            $url = base64_decode($returnURL);
        }

        // The record must either be new (ID zero) or exist
        $id     = $this->input->getInt('id', 0);
        $record = $this->assertValidRecordId($id, $user);

        // If it's a new record we need to read the Method from the request and update the (not yet created) record.
        if ($record->id == 0) {
            $methodName = $this->input->getCmd('method');
            $this->assertMethodExists($methodName);
            $record->method = $methodName;
        }

        /** @var MethodModel $model */
        $model = $this->getModel('Method');

        // Ask the plugin to validate the input by calling onUserMultifactorSaveSetup
        $result = [];
        $input  = $this->app->getInput();

        $event = new NotifyActionLog('onComUsersControllerMethodBeforeSave', [$id, $user]);
        $this->app->getDispatcher()->dispatch($event->getName(), $event);

        try {
            $event         = new SaveSetup($record, $input);
            $pluginResults = $this->app
                ->getDispatcher()
                ->dispatch($event->getName(), $event)
                ->getArgument('result', []);

            foreach ($pluginResults as $pluginResult) {
                $result = array_merge($result, $pluginResult);
            }
        } catch (\RuntimeException $e) {
            // Go back to the edit page
            $nonSefUrl = 'index.php?option=com_users&task=method.';

            if ($id) {
                $nonSefUrl .= 'edit&id=' . (int) $id;
            } else {
                $nonSefUrl .= 'add&method=' . $record->method;
            }

            $nonSefUrl .= '&user_id=' . $userId;

            if (!empty($returnURL)) {
                $nonSefUrl .= '&returnurl=' . urlencode($returnURL);
            }

            $url = Route::_($nonSefUrl, false);
            $this->setRedirect($url, $e->getMessage(), 'error');

            return;
        }

        // Update the record's options with the plugin response
        $title = $this->input->getString('title', null);
        $title = trim($title);

        if (empty($title)) {
            $method = $model->getMethod($record->method);
            $title  = $method['display'];
        }

        // Update the record's "default" flag
        $default         = $this->input->getBool('default', false);
        $record->title   = $title;
        $record->options = $result;
        $record->default = $default ? 1 : 0;

        // Ask the model to save the record
        $saved = $record->store();

        if (!$saved) {
            // Go back to the edit page
            $nonSefUrl = 'index.php?option=com_users&task=method.';

            if ($id) {
                $nonSefUrl .= 'edit&id=' . (int) $id;
            } else {
                $nonSefUrl .= 'add';
            }

            $nonSefUrl .= '&user_id=' . $userId;

            if (!empty($returnURL)) {
                $nonSefUrl .= '&returnurl=' . urlencode($returnURL);
            }

            $url = Route::_($nonSefUrl, false);
            $this->setRedirect($url, $record->getError(), 'error');

            return;
        }

        // Method updated, destroy other active sessions
        UserHelper::destroyUserSessions($userId, true);

        $this->setRedirect($url);
    }

    /**
     * Assert that the provided ID is a valid record identified for the given user
     *
     * @param   int        $id    Record ID to check
     * @param   User|null  $user  User record. Null to use current user.
     *
     * @return  MfaTable  The loaded record
     * @since   4.2.0
     */
    private function assertValidRecordId($id, ?User $user = null): MfaTable
    {
        if (\is_null($user)) {
            $user = $this->app->getIdentity() ?: $this->getUserFactory()->loadUserById(0);
        }

        /** @var MethodModel $model */
        $model = $this->getModel('Method');

        $model->setState('id', $id);

        $record = $model->getRecord($user);

        if (\is_null($record) || ($record->id != $id) || ($record->user_id != $user->id)) {
            throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
        }

        return $record;
    }

    /**
     * Assert that the user can add / edit MFA methods.
     *
     * @param   User|null  $user  User record. Null to use current user.
     *
     * @return  void
     * @throws  \RuntimeException|\Exception
     * @since   4.2.0
     */
    private function assertCanEdit(?User $user = null): void
    {
        if (!MfaHelper::canAddEditMethod($user)) {
            throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
        }
    }

    /**
     * Assert that the user can delete MFA records / disable MFA.
     *
     * @param   User|null  $user  User record. Null to use current user.
     *
     * @return  void
     * @throws  \RuntimeException|\Exception
     * @since   4.2.0
     */
    private function assertCanDelete(?User $user = null): void
    {
        if (!MfaHelper::canDeleteMethod($user)) {
            throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
        }
    }

    /**
     * Assert that the specified MFA Method exists, is activated and enabled for the current user
     *
     * @param   string|null  $method  The Method to check
     *
     * @return  void
     * @since   4.2.0
     */
    private function assertMethodExists(?string $method): void
    {
        /** @var MethodModel $model */
        $model = $this->getModel('Method');

        if (empty($method) || !$model->methodExists($method)) {
            throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
        }
    }

    /**
     * Assert that there is a logged in user.
     *
     * @return  void
     * @since   4.2.0
     */
    private function assertLoggedInUser(): void
    {
        $user = $this->app->getIdentity() ?: $this->getUserFactory()->loadUserById(0);

        if ($user->guest) {
            throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
        }
    }
}
