<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

namespace tool_mfa;

use dml_exception;
use tool_mfa\plugininfo\factor;

/**
 * MFA management class.
 *
 * @package     tool_mfa
 * @author      Peter Burnett <peterburnett@catalyst-au.net>
 * @copyright   Catalyst IT
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class manager {

    /** @var int */
    const REDIRECT = 1;

    /** @var int */
    const NO_REDIRECT = 0;

    /** @var int */
    const REDIRECT_EXCEPTION = -1;

    /** @var int */
    const REDIR_LOOP_THRESHOLD = 5;

    /** @var array These components and related fileareas will not redirect. */
    const ALLOWED_COMPONENTS = [
        'core_admin' => [
            'logocompact',
            'logo',
            'favicon',
        ],
        'tool_mfa' => [
            'guidance',
        ],
    ];

    /**
     * Displays a debug table with current factor information.
     *
     * @return void
     */
    public static function display_debug_notification(): void {
        global $OUTPUT, $PAGE;

        if (!get_config('tool_mfa', 'debugmode')) {
            return;
        }
        $html = $OUTPUT->heading(get_string('debugmode:heading', 'tool_mfa'), 3);

        $table = new \html_table();
        $table->head = [
            get_string('weight', 'tool_mfa'),
            get_string('factor', 'tool_mfa'),
            get_string('setup', 'tool_mfa'),
            get_string('achievedweight', 'tool_mfa'),
            get_string('status'),
        ];
        $table->attributes['class'] = 'admintable generaltable table table-bordered';
        $table->colclasses = [
            'text-right',
            '',
            '',
            'text-right',
            'text-center',
        ];
        $factors = factor::get_enabled_factors();
        $userfactors = factor::get_active_user_factor_types();
        $runningtotal = 0;
        $weighttoggle = false;

        foreach ($factors as $factor) {
            $namespace = 'factor_'.$factor->name;
            $name = get_string('pluginname', $namespace);

            // If factor is unknown, pending from here.
            if ($factor->get_state() == factor::STATE_UNKNOWN) {
                $weighttoggle = true;
            }

            // Stop adding weight if 100 achieved.
            if (!$weighttoggle) {
                $achieved = $factor->get_state() == factor::STATE_PASS ? $factor->get_weight() : 0;
                $achieved = '+'.$achieved;
                $runningtotal += $achieved;
            } else {
                $achieved = '';
            }

            // Setup.
            if ($factor->has_setup()) {
                $found = false;
                foreach ($userfactors as $userfactor) {
                    if ($userfactor->name == $factor->name) {
                        $found = true;
                    }
                }
                $setup = $found ? get_string('yes') : get_string('no');
            } else {
                $setup = get_string('na', 'tool_mfa');
            }

            // Status.
            $OUTPUT = $PAGE->get_renderer('tool_mfa');
            // If toggle has been flipped, fall to default pending badge.
            if ($weighttoggle) {
                $state = $OUTPUT->get_state_badge('');
            } else {
                $state = $OUTPUT->get_state_badge($factor->get_state());
            }

            $table->data[] = [
                $factor->get_weight(),
                $name,
                $setup,
                $achieved,
                $state,
            ];

            // If we just hit 100, flip toggle.
            if ($runningtotal >= 100) {
                $weighttoggle = true;
            }
        }

        $finalstate = self::get_status();
        $table->data[] = [
            '',
            '',
            '<b>' . get_string('overall', 'tool_mfa') . '</b>',
            self::get_cumulative_weight(),
            $OUTPUT->get_state_badge($finalstate),
        ];

        $html .= \html_writer::table($table);
        echo $html;
    }

    /**
     * Returns the total weight from all factors currently enabled for user.
     *
     * @return int
     */
    public static function get_total_weight(): int {
        $totalweight = 0;
        $factors = factor::get_active_user_factor_types();

        foreach ($factors as $factor) {
            if ($factor->get_state() == factor::STATE_PASS) {
                $totalweight += $factor->get_weight();
            }
        }
        return $totalweight;
    }

    /**
     * Checks that provided factorid exists and belongs to current user.
     *
     * @param int $factorid
     * @param object $user
     * @return bool
     * @throws \dml_exception
     */
    public static function is_factorid_valid(int $factorid, object $user): bool {
        global $DB;
        return $DB->record_exists('tool_mfa', ['userid' => $user->id, 'id' => $factorid]);
    }

    /**
     * Function to display to the user that they cannot login, then log them out.
     *
     * @return void
     */
    public static function cannot_login(): void {
        global $ME, $PAGE, $SESSION, $USER;

        // Determine page URL without triggering warnings from $PAGE.
        if (!preg_match("~(\/admin\/tool\/mfa\/auth.php)~", $ME)) {
            // If URL isn't set, we need to redir to auth.php.
            // This ensures URL and required info is correctly set.
            // Then we arrive back here.
            redirect(new \moodle_url('/admin/tool/mfa/auth.php'));
        }

        $renderer = $PAGE->get_renderer('tool_mfa');

        echo $renderer->header();
        if (get_config('tool_mfa', 'debugmode')) {
            self::display_debug_notification();
        }
        echo $renderer->not_enough_factors();
        echo $renderer->footer();
        // Emit an event for failure, then logout.
        $event = \tool_mfa\event\user_failed_mfa::user_failed_mfa_event($USER);
        $event->trigger();

        // We should set the redir flag, as this page is generated through auth.php.
        $SESSION->tool_mfa_has_been_redirected = true;
        die;
    }

    /**
     * Logout user.
     *
     * @return void
     */
    public static function mfa_logout(): void {
        $authsequence = get_enabled_auth_plugins();
        foreach ($authsequence as $authname) {
            $authplugin = get_auth_plugin($authname);
            $authplugin->logoutpage_hook();
        }
        require_logout();
    }

    /**
     * Function to get the overall status of a user's authentication.
     *
     * @return string a STATE variable from plugininfo
     */
    public static function get_status(): string {
        global $SESSION;

        // Check for any instant fail states.
        $factors = factor::get_active_user_factor_types();
        foreach ($factors as $factor) {
            $factor->load_locked_state();

            if ($factor->get_state() == factor::STATE_FAIL) {
                return factor::STATE_FAIL;
            }
        }

        $passcondition = ((isset($SESSION->tool_mfa_authenticated) && $SESSION->tool_mfa_authenticated) ||
            self::passed_enough_factors());

        // Check next factor for instant fail (fallback).
        if (factor::get_next_user_login_factor()->get_state() == factor::STATE_FAIL) {
            // We need to handle a special case here, where someone reached the fallback,
            // If they were able to modify their state on the error page, such as passing iprange,
            // We must return pass.
            if ($passcondition) {
                return factor::STATE_PASS;
            }

            return factor::STATE_FAIL;
        }

        // Now check for general passing state. If found, ensure that session var is set.
        if ($passcondition) {
            return factor::STATE_PASS;
        }

        // Else return neutral state.
        return factor::STATE_NEUTRAL;
    }

    /**
     * Function to check the overall status of a users authentication,
     * and perform any required actions.
     *
     * @param bool $shouldreload whether the function should reload (used for auth.php).
     * @return void
     */
    public static function resolve_mfa_status(bool $shouldreload = false): void {
        global $SESSION;

        $state = self::get_status();
        if ($state == factor::STATE_PASS) {
            self::set_pass_state();
            // Check if user even had to reach auth page.
            if (isset($SESSION->tool_mfa_has_been_redirected)) {
                if (empty($SESSION->wantsurl)) {
                    $wantsurl = '/';
                } else {
                    $wantsurl = $SESSION->wantsurl;
                }
                unset($SESSION->wantsurl);
                redirect(new \moodle_url($wantsurl));
            } else {
                // Don't touch anything, let user be on their way.
                return;
            }
        } else if ($state == factor::STATE_FAIL) {
            self::cannot_login();
        } else if ($shouldreload) {
            // Set a session variable to track whether user is where they want to be.
            $SESSION->tool_mfa_has_been_redirected = true;
            $authurl = new \moodle_url('/admin/tool/mfa/auth.php');
            redirect($authurl);
        }
    }

    /**
     * Checks whether user has passed enough factors to be allowed in.
     *
     * @return bool true if user has passed enough factors.
     */
    public static function passed_enough_factors(): bool {

        // Check for any instant fail states.
        $factors = factor::get_active_user_factor_types();
        foreach ($factors as $factor) {
            if ($factor->get_state() == factor::STATE_FAIL) {
                self::mfa_logout();
            }
        }

        $totalweight = self::get_cumulative_weight();
        if ($totalweight >= 100) {
            return true;
        }

        return false;
    }

    /**
     * Sets the session variable for pass_state, if not already set.
     *
     * @return void
     */
    public static function set_pass_state(): void {
        global $DB, $SESSION, $USER;
        if (!isset($SESSION->tool_mfa_authenticated)) {
            $SESSION->tool_mfa_authenticated = true;
            $event = \tool_mfa\event\user_passed_mfa::user_passed_mfa_event($USER);
            $event->trigger();

            // Allow plugins to callback as soon possible after user has passed MFA.
            $hook = new \tool_mfa\hook\after_user_passed_mfa();
            \core\hook\manager::get_instance()->dispatch($hook);

            // Add/update record in DB for users last mfa auth.
            self::update_pass_time();

            // Unset session vars during mfa auth.
            unset($SESSION->mfa_redir_referer);
            unset($SESSION->mfa_redir_count);

            // Unset user preferences during mfa auth.
            unset_user_preference('mfa_sleep_duration', $USER);

            try {
                // Clear locked user factors, they may now reauth with anything.
                @$DB->set_field('tool_mfa', 'lockcounter', 0, ['userid' => $USER->id]);
                // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
            } catch (\Exception $e) {
                // This occurs when upgrade.php hasn't been run. Nothing to do here.
            }

            // Fire post pass state factor actions.
            $factors = factor::get_active_user_factor_types();
            foreach ($factors as $factor) {
                $factor->post_pass_state();
                // Also set the states for this session to neutral if they were locked.
                if ($factor->get_state() == factor::STATE_LOCKED) {
                    $factor->set_state(factor::STATE_NEUTRAL);
                }
            }

            // Output notifications if any factors were reset for this user.
            $enabledfactors = factor::get_enabled_factors();
            foreach ($enabledfactors as $factor) {
                $pref = 'tool_mfa_reset_' . $factor->name;
                $factorpref = get_user_preferences($pref, false);
                if ($factorpref) {
                    $url = new \moodle_url('/admin/tool/mfa/user_preferences.php');
                    $link = \html_writer::link($url, get_string('preferenceslink', 'tool_mfa'));
                    $data = ['factor' => $factor->get_display_name(), 'url' => $link];
                    \core\notification::warning(get_string('factorreset', 'tool_mfa', $data));
                    unset_user_preference($pref);
                }
            }

            // Also check for a global reset.
            // TODO: Delete this in a few months, the reset all preference is no longer set.
            $allfactor = get_user_preferences('tool_mfa_reset_all', false);
            if ($allfactor) {
                $url = new \moodle_url('/admin/tool/mfa/user_preferences.php');
                $link = \html_writer::link($url, get_string('preferenceslink', 'tool_mfa'));
                \core\notification::warning(get_string('factorresetall', 'tool_mfa', $link));
                unset_user_preference('tool_mfa_reset_all');
            }
        }
    }

    /**
     * Inserts or updates user's last MFA pass time in DB.
     * This should only be called from set_pass_state.
     *
     * @return void
     */
    private static function update_pass_time(): void {
        global $DB, $USER;

        $exists = $DB->record_exists('tool_mfa_auth', ['userid' => $USER->id]);

        if ($exists) {
            $DB->set_field('tool_mfa_auth', 'lastverified', time(), ['userid' => $USER->id]);
        } else {
            $DB->insert_record('tool_mfa_auth', ['userid' => $USER->id, 'lastverified' => time()]);
        }
    }

    /**
     * Checks whether the user should be redirected from the provided url.
     *
     * @param string|\moodle_url $url
     * @param bool|null $preventredirect
     * @return int
     */
    public static function should_require_mfa(string|\moodle_url $url, bool|null $preventredirect): int {
        global $CFG, $USER, $SESSION;

        // If no cookies then no session so cannot do MFA.
        // Unit testing based on defines is not viable.
        if (NO_MOODLE_COOKIES && !PHPUNIT_TEST) {
            return self::NO_REDIRECT;
        }

        // Ensure we have a moodle_url object if a string is provided.
        if (is_string($url)) {
            $url = new \moodle_url($url);
        }

        // Check for pluginfile.php urls.
        $pluginfileurl = new \moodle_url('/pluginfile.php');
        if ($url->compare($pluginfileurl)) {
            // Get the slash arguments.
            $args = explode('/', ltrim($url->get_slashargument(), '/'));

            // Remove the contextid because we do not need it for this check.
            array_shift($args);

            // Get the component and filearea.
            $component = clean_param(array_shift($args), PARAM_COMPONENT);
            $filearea = clean_param(array_shift($args), PARAM_AREA);

            // Check allowed components.
            if (!array_key_exists($component, static::ALLOWED_COMPONENTS)) {
                return self::REDIRECT;
            }

            // Check allowed fileareas.
            if (!in_array($filearea, static::ALLOWED_COMPONENTS[$component])) {
                return self::REDIRECT;
            }

            return self::NO_REDIRECT;
        }

        // Remove all params before comparison.
        $url->remove_all_params();

        // Checks for upgrades pending.
        if (is_siteadmin()) {
            // We should only allow an upgrade from the frontend to complete.
            // After that is completed, only the settings shouldn't redirect.
            // Everything else should be safe to enforce MFA.
            if (moodle_needs_upgrading()) {
                return self::NO_REDIRECT;
            }
            // An upgrade isn't complete if there are settings that must be saved.
            $upgradesettings = new \moodle_url('/admin/upgradesettings.php');
            if ($url->compare($upgradesettings, URL_MATCH_BASE)) {
                return self::NO_REDIRECT;
            }
        }

        // Admin not setup.
        if (!empty($CFG->adminsetuppending)) {
            return self::NO_REDIRECT;
        }

        // Initial installation.
        // We get this for free from get_plugins_with_function.

        // Upgrade check.
        // We get this for free from get_plugins_with_function.

        // Honor prevent_redirect.
        if ($preventredirect) {
            return self::NO_REDIRECT;
        }

        // User not properly setup.
        if (user_not_fully_set_up($USER)) {
            return self::NO_REDIRECT;
        }

        // Guest access.
        if (isguestuser()) {
            return self::NO_REDIRECT;
        }

        // Forced password changes.
        if (get_user_preferences('auth_forcepasswordchange')) {
            return self::NO_REDIRECT;
        }

        // Login as.
        if (\core\session\manager::is_loggedinas()) {
            return self::NO_REDIRECT;
        }

        // Site policy.
        if (isset($USER->policyagreed) && !$USER->policyagreed) {
            $manager = new \core_privacy\local\sitepolicy\manager();
            $policyurl = $manager->get_redirect_url(false);
            if (!empty($policyurl) && $url->compare($policyurl, URL_MATCH_BASE)) {
                return self::NO_REDIRECT;
            }
        }

        // WS/AJAX check.
        if (WS_SERVER || AJAX_SCRIPT) {
            if (isset($SESSION->mfa_pending) && !empty($SESSION->mfa_pending)) {
                // Allow AJAX and WS, but never from auth.php.
                return self::NO_REDIRECT;
            }
            return self::REDIRECT_EXCEPTION;
        }

        // Check factor defined safe urls.
        $factorurls = self::get_no_redirect_urls();
        foreach ($factorurls as $factorurl) {
            if ($factorurl->compare($url)) {
                return self::NO_REDIRECT;
            }
        }

        // Circular checks.
        $authurl = new \moodle_url('/admin/tool/mfa/auth.php');
        $authlocal = $authurl->out_as_local_url();
        if (isset($SESSION->mfa_redir_referer)
            && $SESSION->mfa_redir_referer != $authlocal) {
            if ($SESSION->mfa_redir_referer == get_local_referer(true)) {
                // Possible redirect loop.
                if (!isset($SESSION->mfa_redir_count)) {
                    $SESSION->mfa_redir_count = 1;
                } else {
                    $SESSION->mfa_redir_count++;
                }
                if ($SESSION->mfa_redir_count > self::REDIR_LOOP_THRESHOLD) {
                    return self::REDIRECT_EXCEPTION;
                }
            } else {
                // If not a match, reset counter.
                $SESSION->mfa_redir_count = 0;
            }
        }
        // Set referer after checks.
        $SESSION->mfa_redir_referer = get_local_referer(true);

        // Don't redirect if already on auth.php.
        if ($url->compare($authurl, URL_MATCH_BASE)) {
            return self::NO_REDIRECT;
        }

        return self::REDIRECT;
    }

    /**
     * Clears the redirect counter for infinite redirect loops. Called from auth.php when a valid load is resolved.
     *
     * @return void
     */
    public static function clear_redirect_counter(): void {
        global $SESSION;

        unset($SESSION->mfa_redir_referer);
        unset($SESSION->mfa_redir_count);
    }

    /**
     * Gets all defined factor urls that should not redirect.
     *
     * @return array
     */
    public static function get_no_redirect_urls(): array {
        $factors = factor::get_factors();
        $urls = [
            new \moodle_url('/login/logout.php'),
            new \moodle_url('/admin/tool/mfa/guide.php'),
        ];
        foreach ($factors as $factor) {
            $urls = array_merge($urls, $factor->get_no_redirect_urls());
        }

        // Allow forced redirection exclusions.
        if ($exclusions = get_config('tool_mfa', 'redir_exclusions')) {
            foreach (explode("\n", $exclusions) as $exclusion) {
                $urls[] = new \moodle_url($exclusion);
            }
        }

        return $urls;
    }

    /**
     * Sleeps for an increasing period of time.
     *
     * @return void
     */
    public static function sleep_timer(): void {
        global $USER;

        $duration = get_user_preferences('mfa_sleep_duration', null, $USER);
        if (!empty($duration)) {
            // Double current time.
            $duration *= 2;
            $duration = min(2, $duration);
        } else {
            // No duration set.
            $duration = 0.05;
        }
        set_user_preference('mfa_sleep_duration', $duration, $USER);
        sleep((int)$duration);
    }

    /**
     * If MFA Plugin is ready check tool_mfa_authenticated USER property and
     * start MFA authentication if it's not set or false.
     *
     * @param mixed $courseorid
     * @param mixed $autologinguest
     * @param mixed $cm
     * @param mixed $setwantsurltome
     * @param mixed $preventredirect
     * @return void
     */
    public static function require_auth($courseorid = null, $autologinguest = null, $cm = null,
    $setwantsurltome = null, $preventredirect = null): void {
        global $PAGE, $SESSION, $FULLME;

        // Guest user should never interact with MFA,
        // And $SESSION->tool_mfa_authenticated should never be set in a guest session.
        if (isguestuser()) {
            return;
        }

        if (!self::is_ready()) {
            // Set session var so if MFA becomes ready, you dont get locked from session.
            $SESSION->tool_mfa_authenticated = true;
            return;
        }

        if (empty($SESSION->tool_mfa_authenticated) || !$SESSION->tool_mfa_authenticated) {
            if ($PAGE->has_set_url()) {
                $cleanurl = $PAGE->url;
            } else {
                // Use $FULLME instead.
                $cleanurl = new \moodle_url($FULLME);
            }
            $authurl = new \moodle_url('/admin/tool/mfa/auth.php');

            $redir = self::should_require_mfa($cleanurl, $preventredirect);

            if ($redir == self::NO_REDIRECT && !$cleanurl->compare($authurl, URL_MATCH_BASE)) {
                // A non-MFA page that should take precedence.
                // This check is for any pages, such as site policy, that must occur before MFA.
                // This check allows AJAX and WS requests to fire on these pages without throwing an exception.
                $SESSION->mfa_pending = true;
            }

            if ($redir == self::REDIRECT) {
                if (empty($SESSION->wantsurl)) {
                    !empty($setwantsurltome)
                        ? $SESSION->wantsurl = qualified_me()
                        : $SESSION->wantsurl = new \moodle_url('/');

                    $SESSION->tool_mfa_setwantsurl = true;
                }
                // Remove pending status.
                // We must now auth with MFA, now that pending statuses are resolved.
                unset($SESSION->mfa_pending);

                // Call resolve_status to instantly pass if no redirect is required.
                self::resolve_mfa_status(true);
            } else if ($redir == self::REDIRECT_EXCEPTION) {
                if (!empty($SESSION->mfa_redir_referer)) {
                    throw new \moodle_exception('redirecterrordetected', 'tool_mfa',
                        $SESSION->mfa_redir_referer, $SESSION->mfa_redir_referer);
                } else {
                    throw new \moodle_exception('redirecterrordetected', 'error');
                }
            }
        }
    }

    /**
     * Sets config variable for given factor.
     *
     * @param array $data
     * @param string $factor
     *
     * @return bool true or exception
     * @throws dml_exception
     */
    public static function set_factor_config(array $data, string $factor): bool|dml_exception {
        $factorconf = get_config($factor);
        foreach ($data as $key => $newvalue) {
            if (empty($factorconf->$key)) {
                add_to_config_log($key, null, $newvalue, $factor);
                set_config($key, $newvalue, $factor);
            } else if ($factorconf->$key != $newvalue) {
                add_to_config_log($key, $factorconf->$key, $newvalue, $factor);
                set_config($key, $newvalue, $factor);
            }
        }
        return true;
    }

    /**
     * Checks if MFA Plugin is enabled and has enabled factor.
     * If plugin is disabled or there is no enabled factors,
     * it means there is nothing to do from user side.
     * Thus, login flow shouldn't be extended with MFA.
     *
     * @return bool
     * @throws \dml_exception
     */
    public static function is_ready(): bool {
        global $CFG, $USER;

        if (!empty($CFG->upgraderunning)) {
            return false;
        }

        $pluginenabled = get_config('tool_mfa', 'enabled');
        if (empty($pluginenabled)) {
            return false;
        }

        // Check if user can interact with MFA.
        $usercontext = \context_user::instance($USER->id);
        if (!has_capability('tool/mfa:mfaaccess', $usercontext)) {
            return false;
        }

        $enabledfactors = factor::get_enabled_factors();
        if (count($enabledfactors) == 0) {
            return false;
        }

        return true;
    }

    /**
     * Performs factor actions for given factor.
     * Change factor order and enable/disable.
     *
     * @param string $factorname
     * @param string $action
     *
     * @return void
     * @throws dml_exception
     */
    public static function do_factor_action(string $factorname, string $action): void {
        $order = explode(',', get_config('tool_mfa', 'factor_order'));
        $key = array_search($factorname, $order);

        switch ($action) {
            case 'up':
                if ($key >= 1) {
                    $fsave = $order[$key];
                    $order[$key] = $order[$key - 1];
                    $order[$key - 1] = $fsave;
                }
                break;

            case 'down':
                if ($key < (count($order) - 1)) {
                    $fsave = $order[$key];
                    $order[$key] = $order[$key + 1];
                    $order[$key + 1] = $fsave;
                }
                break;

            case 'enable':
                if (!$key) {
                    $order[] = $factorname;
                }
                break;

            case 'disable':
                if ($key) {
                    unset($order[$key]);
                }
                break;

            default:
                break;
        }
        self::set_factor_config(['factor_order' => implode(',', $order)], 'tool_mfa');
    }

    /**
     * Checks if a factor that can make a user pass can be setup.
     * It checks if a user will always pass regardless,
     * then checks if there are factors that can be setup to let a user pass.
     *
     * @return bool
     */
    public static function possible_factor_setup(): bool {
        global $USER;

        // Get all active factors.
        $factors = factor::get_enabled_factors();

        // Check if there are enough factors that a user can ONLY pass, if so, don't display the menu.
        $weight = 0;
        foreach ($factors as $factor) {
            $states = $factor->possible_states($USER);
            if (count($states) == 1 && reset($states) == factor::STATE_PASS) {
                $weight += $factor->get_weight();
                if ($weight >= 100) {
                    return false;
                }
            }
        }

        // Now if there is a factor that can be setup, that may return a pass state for the user, display menu.
        foreach ($factors as $factor) {
            if ($factor->has_setup()) {
                if (in_array(factor::STATE_PASS, $factor->possible_states($USER))) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Gets current user weight, up until first unknown factor.
     *
     * @return int $totalweight Total weight of all factors.
     */
    public static function get_cumulative_weight(): int {
        $factors = factor::get_active_user_factor_types();
        // Factor order is important here, so sort the factors by state.
        $sortedfactors = factor::sort_factors_by_state($factors, factor::STATE_PASS);
        $totalweight = 0;
        foreach ($sortedfactors as $factor) {
            if ($factor->get_state() == factor::STATE_PASS) {
                $totalweight += $factor->get_weight();
                // If over 100, break. Don't care about >100.
                if ($totalweight >= 100) {
                    break;
                }
            } else if ($factor->get_state() == factor::STATE_UNKNOWN) {
                break;
            }
        }
        return $totalweight;
    }

    /**
     * Checks whether the factor was actually used in the login process.
     *
     * @param string $factorname the name of the factor.
     * @return bool true if factor is pending.
     */
    public static function check_factor_pending(string $factorname): bool {
        $factors = factor::get_active_user_factor_types();
        // Setup vars.
        $pending = [];
        $totalweight = 0;
        $weighttoggle = false;

        foreach ($factors as $factor) {
            // If toggle is reached, put in pending and continue.
            if ($weighttoggle) {
                $pending[] = $factor->name;
                continue;
            }

            if ($factor->get_state() == factor::STATE_PASS) {
                $totalweight += $factor->get_weight();
                if ($totalweight >= 100) {
                    $weighttoggle = true;
                }
            }
        }

        // Check whether factor falls into pending category.
        return in_array($factorname, $pending);
    }
}
