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

use Joomla\CMS\Date\Date;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Table\Table;
use Joomla\CMS\User\CurrentUserInterface;
use Joomla\CMS\User\CurrentUserTrait;
use Joomla\CMS\User\UserFactoryAwareInterface;
use Joomla\CMS\User\UserFactoryAwareTrait;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
use Joomla\Component\Users\Administrator\Service\Encrypt;
use Joomla\Database\DatabaseDriver;
use Joomla\Database\ParameterType;
use Joomla\Event\DispatcherInterface;

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

/**
 * Table for the Multi-Factor Authentication records
 *
 * @property int    $id          Record ID.
 * @property int    $user_id     User ID
 * @property string $title       Record title.
 * @property string $method      MFA Method (corresponds to one of the plugins).
 * @property int    $default     Is this the default Method?
 * @property array  $options     Configuration options for the MFA Method.
 * @property string $created_on  Date and time the record was created.
 * @property string $last_used   Date and time the record was last used successfully.
 * @property int    $tries       Counter for unsuccessful tries
 * @property string $last_try    Date and time of the last unsuccessful try
 *
 * @since 4.2.0
 */
class MfaTable extends Table implements CurrentUserInterface, UserFactoryAwareInterface
{
    use CurrentUserTrait;
    use UserFactoryAwareTrait;

    /**
     * Delete flags per ID, set up onBeforeDelete and used onAfterDelete
     *
     * @var   array
     * @since 4.2.0
     */
    private $deleteFlags = [];

    /**
     * Encryption service
     *
     * @var   Encrypt
     * @since 4.2.0
     */
    private $encryptService;

    /**
     * Indicates that columns fully support the NULL value in the database
     *
     * @var   boolean
     * @since 4.2.0
     */
    // phpcs:ignore
    protected $_supportNullValue = true;

    /**
     * Table constructor
     *
     * @param   DatabaseDriver        $db          Database driver object
     * @param   ?DispatcherInterface  $dispatcher  Events dispatcher object
     *
     * @since 4.2.0
     */
    public function __construct(DatabaseDriver $db, DispatcherInterface $dispatcher = null)
    {
        parent::__construct('#__user_mfa', 'id', $db, $dispatcher);

        $this->encryptService = new Encrypt();
    }

    /**
     * Method to store a row in the database from the Table instance properties.
     *
     * If a primary key value is set the row with that primary key value will be updated with the instance property values.
     * If no primary key value is set a new row will be inserted into the database with the properties from the Table instance.
     *
     * @param   boolean  $updateNulls  True to update fields even if they are null.
     *
     * @return  boolean  True on success.
     *
     * @since 4.2.0
     */
    public function store($updateNulls = true)
    {
        // Encrypt the options before saving them
        $this->options = $this->encryptService->encrypt(json_encode($this->options ?: []));

        // Set last_used date to null if empty or zero date
        if (!((int) $this->last_used)) {
            $this->last_used = null;
        }

        $records = MfaHelper::getUserMfaRecords($this->user_id);

        if ($this->id) {
            // Existing record. Remove it from the list of records.
            $records = array_filter(
                $records,
                function ($rec) {
                    return $rec->id != $this->id;
                }
            );
        }

        // Update the dates on a new record
        if (empty($this->id)) {
            $this->created_on = Date::getInstance()->toSql();
            $this->last_used  = null;
        }

        // Do I need to mark this record as the default?
        if ($this->default == 0) {
            $hasDefaultRecord = array_reduce(
                $records,
                function ($carry, $record) {
                    return $carry || ($record->default == 1);
                },
                false
            );

            $this->default = $hasDefaultRecord ? 0 : 1;
        }

        // Let's find out if we are saving a new MFA method record without having backup codes yet.
        $mustCreateBackupCodes = false;

        if (empty($this->id) && $this->method !== 'backupcodes') {
            // Do I have any backup records?
            $hasBackupCodes = array_reduce(
                $records,
                function (bool $carry, $record) {
                    return $carry || $record->method === 'backupcodes';
                },
                false
            );

            $mustCreateBackupCodes = !$hasBackupCodes;

            // If the only other entry is the backup records one I need to make this the default method
            if ($hasBackupCodes && \count($records) === 1) {
                $this->default = 1;
            }
        }

        // Store the record
        try {
            $result = parent::store($updateNulls);
        } catch (\Throwable $e) {
            $this->setError($e->getMessage());

            $result = false;
        }

        // Decrypt the options (they must be decrypted in memory)
        $this->decryptOptions();

        if ($result) {
            // If this record is the default unset the default flag from all other records
            $this->switchDefaultRecord();

            // Do I need to generate backup codes?
            if ($mustCreateBackupCodes) {
                $this->generateBackupCodes();
            }
        }

        return $result;
    }

    /**
     * Method to load a row from the database by primary key and bind the fields to the Table instance properties.
     *
     * @param   mixed    $keys   An optional primary key value to load the row by, or an array of fields to match.
     *                           If not set the instance property value is used.
     * @param   boolean  $reset  True to reset the default values before loading the new row.
     *
     * @return  boolean  True if successful. False if row not found.
     *
     * @since 4.2.0
     * @throws  \InvalidArgumentException
     * @throws  \RuntimeException
     * @throws  \UnexpectedValueException
     */
    public function load($keys = null, $reset = true)
    {
        $result = parent::load($keys, $reset);

        if ($result) {
            $this->decryptOptions();
        }

        return $result;
    }

    /**
     * Method to delete a row from the database table by primary key value.
     *
     * @param   mixed  $pk  An optional primary key value to delete.  If not set the instance property value is used.
     *
     * @return  boolean  True on success.
     *
     * @since 4.2.0
     * @throws  \UnexpectedValueException
     */
    public function delete($pk = null)
    {
        $record = $this;

        if ($pk != $this->id) {
            $record = clone $this;
            $record->reset();
            $result = $record->load($pk);

            if (!$result) {
                // If the record does not exist I will stomp my feet and deny your request
                throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
            }
        }

        $user = $this->getCurrentUser();

        // The user must be a registered user, not a guest
        if ($user->guest) {
            throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
        }

        // Save flags used onAfterDelete
        $this->deleteFlags[$record->id] = [
            'default'    => $record->default,
            'numRecords' => $this->getNumRecords($record->user_id),
            'user_id'    => $record->user_id,
            'method'     => $record->method,
        ];

        if (\is_null($pk)) {
            $pk = [$this->_tbl_key => $this->id];
        } elseif (!\is_array($pk)) {
            $pk = [$this->_tbl_key => $pk];
        }

        $isDeleted = parent::delete($pk);

        if ($isDeleted) {
            $this->afterDelete($pk);
        }

        return $isDeleted;
    }

    /**
     * Decrypt the possibly encrypted options
     *
     * @return void
     * @since 4.2.0
     */
    private function decryptOptions(): void
    {
        // Try with modern decryption
        $decrypted = @json_decode($this->encryptService->decrypt($this->options ?? ''), true);

        if (\is_string($decrypted)) {
            $decrypted = @json_decode($decrypted, true);
        }

        // Fall back to legacy decryption
        if (!\is_array($decrypted)) {
            $decrypted = @json_decode($this->encryptService->decrypt($this->options ?? '', true), true);

            if (\is_string($decrypted)) {
                $decrypted = @json_decode($decrypted, true);
            }
        }

        $this->options = $decrypted ?: [];
    }

    /**
     * If this record is set to be the default, unset the default flag from the other records for the same user.
     *
     * @return void
     * @since 4.2.0
     */
    private function switchDefaultRecord(): void
    {
        if (!$this->default) {
            return;
        }

        /**
         * This record is marked as default, therefore we need to unset the default flag from all other records for this
         * user.
         */
        $db    = $this->getDbo();
        $query = $db->getQuery(true)
            ->update($db->quoteName('#__user_mfa'))
            ->set($db->quoteName('default') . ' = 0')
            ->where($db->quoteName('user_id') . ' = :user_id')
            ->where($db->quoteName('id') . ' != :id')
            ->bind(':user_id', $this->user_id, ParameterType::INTEGER)
            ->bind(':id', $this->id, ParameterType::INTEGER);
        $db->setQuery($query)->execute();
    }

    /**
     * Regenerate backup code is the flag is set.
     *
     * @return void
     * @throws \Exception
     * @since 4.2.0
     */
    private function generateBackupCodes(): void
    {
        /** @var MVCFactoryInterface $factory */
        $factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory();

        /** @var BackupcodesModel $backupCodes */
        $backupCodes = $factory->createModel('Backupcodes', 'Administrator');
        $user        = $this->getUserFactory()->loadUserById($this->user_id);
        $backupCodes->regenerateBackupCodes($user);
    }

    /**
     * Runs after successfully deleting a record
     *
     * @param   int|array  $pk  The promary key of the deleted record
     *
     * @return void
     * @since 4.2.0
     */
    private function afterDelete($pk): void
    {
        if (\is_array($pk)) {
            $pk = $pk[$this->_tbl_key] ?? array_shift($pk);
        }

        if (!isset($this->deleteFlags[$pk])) {
            return;
        }

        if (($this->deleteFlags[$pk]['numRecords'] <= 2) && ($this->deleteFlags[$pk]['method'] != 'backupcodes')) {
            /**
             * This was the second to last MFA record in the database (the last one is the `backupcodes`). Therefore, we
             * need to delete the remaining entry and go away. We don't trigger this if the Method we are deleting was
             * the `backupcodes` because we might just be regenerating the backup codes.
             */
            $db    = $this->getDbo();
            $query = $db->getQuery(true)
                ->delete($db->quoteName('#__user_mfa'))
                ->where($db->quoteName('user_id') . ' = :user_id')
                ->bind(':user_id', $this->deleteFlags[$pk]['user_id'], ParameterType::INTEGER);
            $db->setQuery($query)->execute();

            unset($this->deleteFlags[$pk]);

            return;
        }

        // This was the default record. Promote the next available record to default.
        if ($this->deleteFlags[$pk]['default']) {
            $db    = $this->getDbo();
            $query = $db->getQuery(true)
                ->select($db->quoteName('id'))
                ->from($db->quoteName('#__user_mfa'))
                ->where($db->quoteName('user_id') . ' = :user_id')
                ->where($db->quoteName('method') . ' != ' . $db->quote('backupcodes'))
                ->bind(':user_id', $this->deleteFlags[$pk]['user_id'], ParameterType::INTEGER);
            $ids   = $db->setQuery($query)->loadColumn();

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

            $id    = array_shift($ids);
            $query = $db->getQuery(true)
                ->update($db->quoteName('#__user_mfa'))
                ->set($db->quoteName('default') . ' = 1')
                ->where($db->quoteName('id') . ' = :id')
                ->bind(':id', $id, ParameterType::INTEGER);
            $db->setQuery($query)->execute();
        }
    }

    /**
     * Get the number of MFA records for a give user ID
     *
     * @param   int  $userId  The user ID to check
     *
     * @return  integer
     *
     * @since 4.2.0
     */
    private function getNumRecords(int $userId): int
    {
        $db    = $this->getDbo();
        $query = $db->getQuery(true)
            ->select('COUNT(*)')
            ->from($db->quoteName('#__user_mfa'))
            ->where($db->quoteName('user_id') . ' = :user_id')
            ->bind(':user_id', $userId, ParameterType::INTEGER);
        $numOldRecords = $db->setQuery($query)->loadResult();

        return (int) $numOldRecords;
    }
}
