<?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\Model;

use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Event\MultiFactor\Captive;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\User\User;
use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Component\Users\Administrator\Table\MfaTable;
use Joomla\Event\Event;

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

/**
 * Captive Multi-factor Authentication page's model
 *
 * @since 4.2.0
 */
class CaptiveModel extends BaseDatabaseModel
{
    /**
     * Cache of the names of the currently active MFA Methods
     *
     * @var  array|null
     * @since 4.2.0
     */
    protected $activeMFAMethodNames = null;

    /**
     * Prevents Joomla from displaying any modules.
     *
     * This is implemented with a trick. If you use jdoc tags to load modules the JDocumentRendererHtmlModules
     * uses JModuleHelper::getModules() to load the list of modules to render. This goes through JModuleHelper::load()
     * which triggers the onAfterModuleList event after cleaning up the module list from duplicates. By resetting
     * the list to an empty array we force Joomla to not display any modules.
     *
     * Similar code paths are followed by any canonical code which tries to load modules. So even if your template does
     * not use jdoc tags this code will still work as expected.
     *
     * @param   CMSApplication|null  $app  The CMS application to manipulate
     *
     * @return  void
     * @throws  \Exception
     *
     * @since 4.2.0
     */
    public function suppressAllModules(CMSApplication $app = null): void
    {
        if (is_null($app)) {
            $app = Factory::getApplication();
        }

        $app->registerEvent('onAfterModuleList', [$this, 'onAfterModuleList']);
    }

    /**
     * Get the MFA records for the user which correspond to active plugins
     *
     * @param   User|null  $user                The user for which to fetch records. Skip to use the current user.
     * @param   bool       $includeBackupCodes  Should I include the backup codes record?
     *
     * @return  array
     * @throws  \Exception
     *
     * @since 4.2.0
     */
    public function getRecords(User $user = null, bool $includeBackupCodes = false): array
    {
        if (is_null($user)) {
            $user = $this->getCurrentUser();
        }

        // Get the user's MFA records
        $records = MfaHelper::getUserMfaRecords($user->id);

        // No MFA Methods? Then we obviously don't need to display a Captive login page.
        if (empty($records)) {
            return [];
        }

        // Get the enabled MFA Methods' names
        $methodNames = $this->getActiveMethodNames();

        // Filter the records based on currently active MFA Methods
        $ret = [];

        $methodNames[] = 'backupcodes';
        $methodNames   = array_unique($methodNames);

        if (!$includeBackupCodes) {
            $methodNames = array_filter(
                $methodNames,
                function ($method) {
                    return $method != 'backupcodes';
                }
            );
        }

        foreach ($records as $record) {
            // Backup codes must not be included in the list. We add them in the View, at the end of the list.
            if (in_array($record->method, $methodNames)) {
                $ret[$record->id] = $record;
            }
        }

        return $ret;
    }

    /**
     * Return all the active MFA Methods' names
     *
     * @return  array
     * @since 4.2.0
     */
    private function getActiveMethodNames(): ?array
    {
        if (!is_null($this->activeMFAMethodNames)) {
            return $this->activeMFAMethodNames;
        }

        // Let's get a list of all currently active MFA Methods
        $mfaMethods = MfaHelper::getMfaMethods();

        // If no MFA Method is active we can't really display a Captive login page.
        if (empty($mfaMethods)) {
            $this->activeMFAMethodNames = [];

            return $this->activeMFAMethodNames;
        }

        // Get a list of just the Method names
        $this->activeMFAMethodNames = [];

        foreach ($mfaMethods as $mfaMethod) {
            $this->activeMFAMethodNames[] = $mfaMethod['name'];
        }

        return $this->activeMFAMethodNames;
    }

    /**
     * Get the currently selected MFA record for the current user. If the record ID is empty, it does not correspond to
     * the currently logged in user or does not correspond to an active plugin null is returned instead.
     *
     * @param   User|null  $user  The user for which to fetch records. Skip to use the current user.
     *
     * @return  MfaTable|null
     * @throws  \Exception
     *
     * @since 4.2.0
     */
    public function getRecord(?User $user = null): ?MfaTable
    {
        $id = (int) $this->getState('record_id', null);

        if ($id <= 0) {
            return null;
        }

        if (is_null($user)) {
            $user = $this->getCurrentUser();
        }

        /** @var MfaTable $record */
        $record = $this->getTable('Mfa', 'Administrator');
        $loaded = $record->load(
            [
                'user_id' => $user->id,
                'id'      => $id,
            ]
        );

        if (!$loaded) {
            return null;
        }

        $methodNames = $this->getActiveMethodNames();

        if (!in_array($record->method, $methodNames) && ($record->method != 'backupcodes')) {
            return null;
        }

        return $record;
    }

    /**
     * Load the Captive login page render options for a specific MFA record
     *
     * @param   MfaTable  $record  The MFA record to process
     *
     * @return  CaptiveRenderOptions  The rendering options
     * @since 4.2.0
     */
    public function loadCaptiveRenderOptions(?MfaTable $record): CaptiveRenderOptions
    {
        $renderOptions = new CaptiveRenderOptions();

        if (empty($record)) {
            return $renderOptions;
        }

        $event   = new Captive($record);
        $results = Factory::getApplication()
            ->getDispatcher()
            ->dispatch($event->getName(), $event)
            ->getArgument('result', []);

        if (empty($results)) {
            if ($record->method === 'backupcodes') {
                return $renderOptions->merge(
                    [
                        'pre_message' => Text::_('COM_USERS_USER_BACKUPCODES_CAPTIVE_PROMPT'),
                        'input_type'  => 'number',
                        'label'       => Text::_('COM_USERS_USER_BACKUPCODE'),
                    ]
                );
            }

            return $renderOptions;
        }

        foreach ($results as $result) {
            if (empty($result)) {
                continue;
            }

            return $renderOptions->merge($result);
        }

        return $renderOptions;
    }

    /**
     * Returns the title to display in the Captive login page, or an empty string if no title is to be displayed.
     *
     * @return  string
     * @since 4.2.0
     */
    public function getPageTitle(): string
    {
        // In the frontend we can choose if we will display a title
        $showTitle = (bool) ComponentHelper::getParams('com_users')
            ->get('frontend_show_title', 1);

        if (!$showTitle) {
            return '';
        }

        return Text::_('COM_USERS_USER_MULTIFACTOR_AUTH');
    }

    /**
     * Translate a MFA Method's name into its human-readable, display name
     *
     * @param   string  $name  The internal MFA Method name
     *
     * @return  string
     * @since 4.2.0
     */
    public function translateMethodName(string $name): string
    {
        static $map = null;

        if (!is_array($map)) {
            $map        = [];
            $mfaMethods = MfaHelper::getMfaMethods();

            if (!empty($mfaMethods)) {
                foreach ($mfaMethods as $mfaMethod) {
                    $map[$mfaMethod['name']] = $mfaMethod['display'];
                }
            }
        }

        if ($name == 'backupcodes') {
            return Text::_('COM_USERS_USER_BACKUPCODES');
        }

        return $map[$name] ?? $name;
    }

    /**
     * Translate a MFA Method's name into the relative URL if its logo image
     *
     * @param   string  $name  The internal MFA Method name
     *
     * @return  string
     * @since 4.2.0
     */
    public function getMethodImage(string $name): string
    {
        static $map = null;

        if (!is_array($map)) {
            $map        = [];
            $mfaMethods = MfaHelper::getMfaMethods();

            if (!empty($mfaMethods)) {
                foreach ($mfaMethods as $mfaMethod) {
                    $map[$mfaMethod['name']] = $mfaMethod['image'];
                }
            }
        }

        if ($name == 'backupcodes') {
            return 'media/com_users/images/emergency.svg';
        }

        return $map[$name] ?? $name;
    }

    /**
     * Process the modules list on Joomla! 4.
     *
     * Joomla! 4.x is passing an Event object. The first argument of the event object is the array of modules. After
     * filtering it we have to overwrite the event argument (NOT just return the new list of modules). If a future
     * version of Joomla! uses immutable events we'll have to use Reflection to do that or Joomla! would have to fix
     * the way this event is handled, taking its return into account. For now, we just abuse the mutable event
     * properties - a feature of the event objects we discussed in the Joomla! 4 Working Group back in August 2015.
     *
     * @param   Event  $event  The Joomla! event object
     *
     * @return  void
     * @throws  \Exception
     *
     * @since 4.2.0
     */
    public function onAfterModuleList(Event $event): void
    {
        $modules = $event->getArgument(0);

        if (empty($modules)) {
            return;
        }

        $this->filterModules($modules);

        $event->setArgument(0, $modules);
    }

    /**
     * This is the Method which actually filters the sites modules based on the allowed module positions specified by
     * the user.
     *
     * @param   array  $modules  The list of the site's modules. Passed by reference.
     *
     * @return  void  The by-reference value is modified instead.
     * @since 4.2.0
     * @throws  \Exception
     */
    private function filterModules(array &$modules): void
    {
        $allowedPositions = $this->getAllowedModulePositions();

        if (empty($allowedPositions)) {
            $modules = [];

            return;
        }

        $filtered = [];

        foreach ($modules as $module) {
            if (in_array($module->position, $allowedPositions)) {
                $filtered[] = $module;
            }
        }

        $modules = $filtered;
    }

    /**
     * Get a list of module positions we are allowed to display
     *
     * @return  array
     * @throws  \Exception
     *
     * @since 4.2.0
     */
    private function getAllowedModulePositions(): array
    {
        $isAdmin = Factory::getApplication()->isClient('administrator');

        // Load the list of allowed module positions from the component's settings. May be different for front- and back-end
        $configKey = 'allowed_positions_' . ($isAdmin ? 'backend' : 'frontend');
        $res       = ComponentHelper::getParams('com_users')->get($configKey, []);

        // In the backend we must always add the 'title' module position
        if ($isAdmin) {
            $res[] = 'title';
            $res[] = 'toolbar';
        }

        return $res;
    }

    /**
     * Method to check if the mfa method in question has reached it's usage limit
     *
     * @param   MfaTable  $method  Mfa method record
     *
     * @return  boolean true if user can use the method, false if not
     *
     * @since    4.3.2
     * @throws  \Exception
     */
    public function checkTryLimit(MfaTable $method)
    {
        $params     = ComponentHelper::getParams('com_users');
        $jNow       = Date::getInstance();
        $maxTries   = (int) $params->get('mfatrycount', 10);
        $blockHours = (int) $params->get('mfatrytime', 1);

        $lastTryTime       = strtotime($method->last_try) ?: 0;
        $hoursSinceLastTry = (strtotime(Factory::getDate()->toSql()) - $lastTryTime) / 3600;

        if ($method->last_try !== null && $hoursSinceLastTry > $blockHours) {
            // If it's been long enough, start a new reset count
            $method->last_try = null;
            $method->tries    = 0;
        } elseif ($method->tries < $maxTries) {
            // If we are under the max count, just increment the counter
            ++$method->tries;
            $method->last_try = $jNow->toSql();
        } else {
            // At this point, we know we have exceeded the maximum resets for the time period
            return false;
        }

        // Store changes to try counter and/or the timestamp
        $method->store();

        return true;
    }
}
