<?php

declare(strict_types=1);

namespace SimpleSAML\Module\authswitcher\Auth\Process;

use Detection\MobileDetect;
use SimpleSAML\Auth\State;
use SimpleSAML\Configuration;
use SimpleSAML\Error\Exception;
use SimpleSAML\Logger;
use SimpleSAML\Module;
use SimpleSAML\Module\authswitcher\AuthnContextHelper;
use SimpleSAML\Module\authswitcher\AuthSwitcher;
use SimpleSAML\Module\authswitcher\ContextSettings;
use SimpleSAML\Module\authswitcher\ProxyHelper;
use SimpleSAML\Module\authswitcher\Utils;
use SimpleSAML\Utils\HTTP;

class SwitchAuth extends \SimpleSAML\Auth\ProcessingFilter
{
    /* constants */
    private const DEBUG_PREFIX = 'authswitcher:SwitchAuth: ';

    private const SETUP_MFA_URL = 'authswitcher/setupMFA.php';

    public const SETUP_MFA_TPL_URL = 'authswitcher:setup-mfa-tpl.php';

    public const PARAM_MFA_REDIRECT_URL = 'mfa_redirect_url';

    private $type_filter_array = [
        'TOTP' => 'privacyidea:PrivacyideaAuthProc',
        'WebAuthn' => 'privacyidea:PrivacyideaAuthProc',
    ];

    private $mobile_friendly_filters = ['privacyidea:PrivacyideaAuthProc', 'totp:Totp'];

    private $mfa_preferred_privacyidea_fail = false;

    /**
     * Associative array with keys of the form 'module:filter', values are config arrays to be passed to filters.
     */
    private $configs = [];

    /**
     * Second constructor parameter.
     */
    private $reserved;

    private $config;

    private $proxyMode = false;

    private $token_type_attr = 'type';

    private $preferred_filter;

    private $max_user_capability_attr = 'maxUserCapability';

    /**
     * Maximum Authentication assurance.
     */
    private $max_auth = 'https://id.muni.cz/profile/maxAuth';

    private $check_entropy = false;

    private $sfa_alphabet_attr = 'sfaAlphabet';

    private $sfa_len_attr = 'sfaLen';

    private $change_weak_password_urls;

    private $entityID;

    private $mfa_excluded_sps;

    private $setup_mfa_redirect_url;

    /**
     * @override
     *
     * @param mixed $config
     * @param mixed $reserved
     */
    public function __construct($config, $reserved)
    {
        parent::__construct($config, $reserved);

        $this->config = $config;
        $this->reserved = $reserved;
        $config = isset($config['config']) ? Configuration::loadFromArray(
            $config['config']
        ) : Configuration::getOptionalConfig('module_authswitcher.php');
        $this->type_filter_array = $config->getArray('type_filter_array', $this->type_filter_array);
        $this->mobile_friendly_filters = $config->getArray('mobile_friendly_filters', $this->mobile_friendly_filters);
        $this->token_type_attr = $config->getString('token_type_attr', $this->token_type_attr);
        $this->preferred_filter = $config->getString('preferred_filter', $this->preferred_filter);
        $this->proxyMode = $config->getBoolean('proxy_mode', $this->proxyMode);
        $this->mfa_preferred_privacyidea_fail = $config->getBoolean(
            'mfa_preferred_privacyidea_fail',
            $this->mfa_preferred_privacyidea_fail
        );
        $this->max_user_capability_attr = $config->getString(
            'max_user_capability_attr',
            $this->max_user_capability_attr
        );
        $this->max_auth = $config->getString('max_auth', $this->max_auth);
        $this->sfa_alphabet_attr = $config->getString('sfa_alphabet_attr', $this->sfa_alphabet_attr);
        $this->sfa_len_attr = $config->getString('sfa_len_attr', $this->sfa_len_attr);
        $this->check_entropy = $config->getBoolean('check_entropy', $this->check_entropy);
        $this->entityID = $config->getValue('entityID', null);
        $this->mfa_excluded_sps = $config->getArray('mfa_excluded_sps', []);
        $this->setup_mfa_redirect_url = $config->getString('setup_mfa_redirect_url', "");

        $this->change_weak_password_urls = $config->getArray('change_weak_password_urls', []);

        list($this->password_contexts, $this->mfa_contexts, $password_contexts_patterns, $mfa_contexts_patterns)
            = ContextSettings::parseConfig($config);

        $this->authnContextHelper = new AuthnContextHelper(
            $this->password_contexts,
            $this->mfa_contexts,
            $password_contexts_patterns,
            $mfa_contexts_patterns
        );
    }

    /**
     * @override
     *
     * @param mixed $state
     */
    public function process(&$state)
    {
        $this->getConfig($this->config);

        $rpIdentifier = self::getEntityID($this->entityID, $state);

        $mfaEnforced = Utils::isMFAEnforced($state, $rpIdentifier);

        $usersCapabilities = $this->getMFAForUid($state);

        $upstreamContext = $this->proxyMode ? ProxyHelper::fetchContextFromUpstreamIdp($state) : null;

        self::info('user capabilities: ' . json_encode($usersCapabilities));

        self::setErrorHandling($state);

        if ($this->proxyMode) {
            self::info('upstream context: ' . $upstreamContext);
            ProxyHelper::recoverSPRequestedContexts($state);
        }

        $weak_password = false;

        $this->supported_requested_contexts = $this->authnContextHelper->getSupportedRequestedContexts(
            $usersCapabilities,
            $state,
            $upstreamContext,
            !$this->check_entropy || $this->checkSfaEntropy($state['Attributes']),
            $weak_password,
            $this->change_weak_password_urls
        );

        self::info('supported requested contexts: ' . json_encode($this->supported_requested_contexts));

        $mustPerformMFA = $this->authnContextHelper->MFAin([$upstreamContext]) ? false
            : ($weak_password || $mfaEnforced || empty($this->supported_requested_contexts));
        $shouldPerformMFA = $this->authnContextHelper->MFAin([$upstreamContext]) ? false
            : ($mustPerformMFA || $this->authnContextHelper->isMFAprefered($this->supported_requested_contexts));

        if (
            $this->mfa_preferred_privacyidea_fail
            && !empty($state[AuthSwitcher::PRIVACY_IDEA_FAIL])
            && $shouldPerformMFA
        ) {
            throw new Exception(self::DEBUG_PREFIX . 'MFA should be performed but connection to privacyidea failed.');
        }

        if (
            $mustPerformMFA
            && empty($state['Attributes'][AuthSwitcher::MFA_TOKENS])
            && !empty($this->setup_mfa_redirect_url)
            && !in_array($rpIdentifier, $this->mfa_excluded_sps)
        ) {
            self::info('user must perform MFA but has no tokens, redirect to setup');
            $url = Module::getModuleURL(self::SETUP_MFA_URL);
            $state[self::PARAM_MFA_REDIRECT_URL] = $this->setup_mfa_redirect_url;
            $stateId = State::saveState($state, 'authswitcher:authswitcher');
            HTTP::redirectTrustedURL($url, ['stateId' => $stateId]);
            exit;
        }
        if (empty($this->supported_requested_contexts)) {
            Logger::info('authswitcher: no requested AuthnContext can be fulfilled');
            AuthnContextHelper::noAuthnContextResponder($state);
        }

        // switch to MFA if enforced or preferred but not already done if we handle the proxy mode
        $performMFA = $this->authnContextHelper->MFAin($usersCapabilities) && $shouldPerformMFA;

        $maxUserCapability = '';
        if (
            in_array(AuthSwitcher::REFEDS_MFA, $usersCapabilities, true) || $this->authnContextHelper->MFAin([
                $upstreamContext,
            ])
        ) {
            $maxUserCapability = AuthSwitcher::REFEDS_MFA;
        } elseif (count($usersCapabilities) === 1) {
            $maxUserCapability = $usersCapabilities[0];
        }
        $state['Attributes'][$this->max_user_capability_attr] = [];

        if ($performMFA) {
            $this->performMFA($state, $maxUserCapability);
        } else {
            // SFA or MFA was done at upstream IdP
            $this->setAuthnContext($state, $maxUserCapability, $upstreamContext);
        }
    }

    public function setAuthnContext(&$state, $maxUserCapability, $upstreamContext = null)
    {
        $state[AuthSwitcher::MFA_PERFORMED] = !empty($state[AuthSwitcher::MFA_BEING_PERFORMED])
            || $this->authnContextHelper->MFAin([$upstreamContext]);

        if (
            $maxUserCapability === AuthSwitcher::REFEDS_SFA
            || ($maxUserCapability === AuthSwitcher::REFEDS_MFA && $state[AuthSwitcher::MFA_PERFORMED])
        ) {
            $state['Attributes'][$this->max_user_capability_attr][] = $this->max_auth;
        }

        $possibleReplies = $state[AuthSwitcher::MFA_PERFORMED] ? array_merge(
            $this->mfa_contexts,
            $this->password_contexts
        ) : $this->password_contexts;
        $possibleReplies = array_values(array_intersect($possibleReplies, $this->supported_requested_contexts));
        if (empty($possibleReplies)) {
            AuthnContextHelper::noAuthnContextResponder($state);
        } else {
            $state['saml:AuthnContextClassRef'] = $possibleReplies[0];
        }
    }

    private function checkSfaEntropy($attributes)
    {
        if (
            !$this->sfa_len_attr || !$this->sfa_alphabet_attr || !in_array(
                $this->sfa_alphabet_attr,
                $attributes,
                true
            ) || !in_array($this->sfa_len_attr, $attributes, true)
        ) {
            return false;
        }

        if ($attributes[$this->sfa_alphabet_attr][0] >= 52 && $attributes[$this->sfa_len_attr][0] >= 12) {
            return true;
        }
        if ($attributes[$this->sfa_alphabet_attr][0] >= 72 && $attributes[$this->sfa_len_attr][0] >= 8) {
            return true;
        }

        return false;
    }

    /**
     * Handle NoAuthnContext errors by SAML responses.
     *
     * @param mixed $state
     */
    private static function setErrorHandling(&$state)
    {
        $error_state = State::cloneState($state);
        unset($error_state[State::EXCEPTION_HANDLER_URL]);
        $error_state[State::EXCEPTION_HANDLER_FUNC]
            = ['\\SimpleSAML\\Module\\saml\\IdP\\SAML2', 'handleAuthError'];
        $state[AuthSwitcher::ERROR_STATE_ID] = State::saveState($error_state, Authswitcher::ERROR_STATE_STAGE);
    }

    /**
     * Log a warning.
     *
     * @param $message
     */
    private function warning($message)
    {
        Logger::warning(self::DEBUG_PREFIX . $message);
    }

    /**
     * Log an info.
     *
     * @param $message
     */
    private function info($message)
    {
        Logger::info(self::DEBUG_PREFIX . $message);
    }

    /**
     * Get configuration parameters from the config array.
     */
    private function getConfig(array $config)
    {
        if (!is_array($config['configs'])) {
            throw new Exception(self::DEBUG_PREFIX . 'Configurations are missing.');
        }
        $filterModules = array_keys($config['configs']);
        $invalidModules = Utils::areFilterModulesEnabled($filterModules);
        if ($invalidModules !== true) {
            $this->warning(
                'Some modules (' . implode(',', $invalidModules) . ')'
                . ' in the configuration are missing or disabled.'
            );
        }
        $this->configs = $config['configs'];
    }

    private function getMFAForUid($state)
    {
        $result = [];
        if (!empty($state['Attributes'][AuthSwitcher::MFA_TOKENS])) {
            foreach ($state['Attributes'][AuthSwitcher::MFA_TOKENS] as $mfaToken) {
                if (is_string($mfaToken)) {
                    $mfaToken = json_decode($mfaToken, true);
                }

                foreach ($this->type_filter_array as $type => $method) {
                    if ($mfaToken['revoked'] === false && $mfaToken[$this->token_type_attr] === $type) {
                        $result[] = AuthSwitcher::REFEDS_MFA;
                        break;
                    }
                }
                if (!empty($result)) {
                    break;
                }
            }
        }
        $result[] = AuthSwitcher::REFEDS_SFA;

        return $result;
    }

    private function getActiveMethod(&$state)
    {
        $result = [];
        if (!empty($state['Attributes'][AuthSwitcher::MFA_TOKENS])) {
            foreach ($state['Attributes'][AuthSwitcher::MFA_TOKENS] as $mfaToken) {
                if (is_string($mfaToken)) {
                    $mfaToken = json_decode($mfaToken, true);
                }

                foreach ($this->type_filter_array as $type => $filter) {
                    if ($mfaToken['revoked'] === false && $mfaToken[$this->token_type_attr] === $type) {
                        $result[] = $filter;
                    }
                }
            }
        }
        $result = array_values(array_unique($result));
        $detect = new MobileDetect();
        $mobile_pref = $detect->isMobile();
        if ($result === []) {
            return null;
        }
        $state['Attributes']['MFA_FILTERS'] = $result;
        if ($this->preferred_filter !== null && in_array($this->preferred_filter, $result, true)) {
            return $this->preferred_filter;
        }
        if ($mobile_pref) {
            foreach ($result as $filter) {
                if (in_array($filter, $this->mobile_friendly_filters, true)) {
                    return $filter;
                }
            }
        }

        return $result[0];
    }

    /**
     * Perform the appropriate MFA.
     *
     * @param mixed $state
     * @param $maxUserCapability
     */
    private function performMFA(&$state, $maxUserCapability)
    {
        $filter = $this->getActiveMethod($state);

        if (empty($filter)) {
            throw new Exception(
                self::DEBUG_PREFIX . 'Inconsistent data - no MFA methods for a user who should be able to do MFA.'
            );
        }

        if (!isset($this->configs[$filter])) {
            throw new Exception(self::DEBUG_PREFIX . 'Configuration for ' . $filter . ' is missing.');
        }

        if (!isset($state[AuthSwitcher::MFA_BEING_PERFORMED])) {
            $state[AuthSwitcher::MFA_BEING_PERFORMED] = true;
        }
        $this->setAuthnContext($state, $maxUserCapability);
        $state['Attributes']['Config'] = json_encode($this->configs);
        if ($this->reserved === null) {
            $this->reserved = '';
        }
        $state['Attributes']['Reserved'] = $this->reserved;
        $state['Attributes']['MFA_FILTER_INDEX'] = array_search($filter, $state['Attributes']['MFA_FILTERS'], true);
        Utils::runAuthProcFilter($filter, $this->configs[$filter], $state, $this->reserved);
    }

    private static function getEntityID($entityID, $request)
    {
        if ($entityID === null) {
            return $request['SPMetadata']['entityid'];
        }
        if (is_callable($entityID)) {
            return call_user_func($entityID, $request);
        }
        if (!is_string($entityID)) {
            throw new Exception(
                self::DEBUG_PREFIX . 'Invalid configuration option entityID. It must be a string or a callable.'
            );
        }
        return $entityID;
    }
}