<?php

declare(strict_types=1);

namespace SimpleSAML;

use SAML2\Constants;
use SAML2\Exception\Protocol\NoPassiveException;
use SimpleSAML\Assert\Assert;
use SimpleSAML\Auth;
use SimpleSAML\Configuration;
use SimpleSAML\Error;
use SimpleSAML\IdP\IFrameLogoutHandler;
use SimpleSAML\IdP\LogoutHandlerInterface;
use SimpleSAML\IdP\TraditionalLogoutHandler;
use SimpleSAML\Metadata\MetaDataStorageHandler;
use SimpleSAML\Utils;

/**
 * IdP class.
 *
 * This class implements the various functions used by IdP.
 *
 * @package SimpleSAMLphp
 */

class IdP
{
    /**
     * A cache for resolving IdP id's.
     *
     * @var array
     */
    private static array $idpCache = [];

    /**
     * The identifier for this IdP.
     *
     * @var string
     */
    private string $id;

    /**
     * The "association group" for this IdP.
     *
     * We use this to support cross-protocol logout until
     * we implement a cross-protocol IdP.
     *
     * @var string
     */
    private string $associationGroup;

    /**
     * The configuration for this IdP.
     *
     * @var \SimpleSAML\Configuration
     */
    private Configuration $config;

    /**
     * Our authsource.
     *
     * @var \SimpleSAML\Auth\Simple
     */
    private Auth\Simple $authSource;


    /**
     * Initialize an IdP.
     *
     * @param string $id The identifier of this IdP.
     *
     * @throws \SimpleSAML\Error\Exception If the IdP is disabled or no such auth source was found.
     */
    private function __construct(string $id)
    {
        $this->id = $id;
        $this->associationGroup = $id;

        $metadata = MetaDataStorageHandler::getMetadataHandler();
        $globalConfig = Configuration::getInstance();

        if (substr($id, 0, 6) === 'saml2:') {
            if (!$globalConfig->getOptionalBoolean('enable.saml20-idp', false)) {
                throw new Error\Exception('enable.saml20-idp disabled in config.php.');
            }
            $this->config = $metadata->getMetaDataConfig(substr($id, 6), 'saml20-idp-hosted');
        } elseif (substr($id, 0, 5) === 'adfs:') {
            if (!$globalConfig->getOptionalBoolean('enable.adfs-idp', false)) {
                throw new Error\Exception('enable.adfs-idp disabled in config.php.');
            }
            $this->config = $metadata->getMetaDataConfig(substr($id, 5), 'adfs-idp-hosted');

            if ($globalConfig->getOptionalBoolean('enable.saml20-idp', false)) {
                try {
                    // this makes the ADFS IdP use the same SP associations as the SAML 2.0 IdP
                    $saml2EntityId = $metadata->getMetaDataCurrentEntityID('saml20-idp-hosted');
                    $this->associationGroup = 'saml2:' . $saml2EntityId;
                } catch (\Exception $e) {
                    // probably no SAML 2 IdP configured for this host. Ignore the error
                }
            }
        } else {
            throw new \Exception("Protocol not implemented.");
        }

        $auth = $this->config->getString('auth');
        if (Auth\Source::getById($auth) !== null) {
            $this->authSource = new Auth\Simple($auth);
        } else {
            throw new Error\Exception('No such "' . $auth . '" auth source found.');
        }
    }


    /**
     * Retrieve the ID of this IdP.
     *
     * @return string The ID of this IdP.
     */
    public function getId(): string
    {
        return $this->id;
    }


    /**
     * Retrieve an IdP by ID.
     *
     * @param string $id The identifier of the IdP.
     *
     * @return \SimpleSAML\IdP The IdP.
     */
    public static function getById(string $id): IdP
    {
        if (isset(self::$idpCache[$id])) {
            return self::$idpCache[$id];
        }

        $idp = new self($id);
        self::$idpCache[$id] = $idp;
        return $idp;
    }


    /**
     * Retrieve the IdP "owning" the state.
     *
     * @param array &$state The state array.
     *
     * @return \SimpleSAML\IdP The IdP.
     */
    public static function getByState(array &$state): IdP
    {
        Assert::notNull($state['core:IdP']);

        return self::getById($state['core:IdP']);
    }


    /**
     * Retrieve the configuration for this IdP.
     *
     * @return Configuration The configuration object.
     */
    public function getConfig(): Configuration
    {
        return $this->config;
    }


    /**
     * Get SP name.
     * Only used in IFrameLogout it seems.
     * TODO: probably replace with template Template::getEntityDisplayName()
     *
     * @param string $assocId The association identifier.
     *
     * @return array|null The name of the SP, as an associative array of language => text, or null if this isn't an SP.
     */
    public function getSPName(string $assocId): ?array
    {
        $prefix = substr($assocId, 0, 4);
        $spEntityId = substr($assocId, strlen($prefix) + 1);
        $metadata = MetaDataStorageHandler::getMetadataHandler();

        if ($prefix === 'saml') {
            try {
                $spMetadata = $metadata->getMetaDataConfig($spEntityId, 'saml20-sp-remote');
            } catch (\Exception $e) {
                return null;
            }
        } else {
            if ($prefix === 'adfs') {
                $spMetadata = $metadata->getMetaDataConfig($spEntityId, 'adfs-sp-remote');
            } else {
                return null;
            }
        }

        if ($spMetadata->hasValue('name')) {
            return $spMetadata->getLocalizedString('name');
        } elseif ($spMetadata->hasValue('OrganizationDisplayName')) {
            return $spMetadata->getLocalizedString('OrganizationDisplayName');
        } else {
            return ['en' => $spEntityId];
        }
    }


    /**
     * Add an SP association.
     *
     * @param array $association The SP association.
     */
    public function addAssociation(array $association): void
    {
        Assert::notNull($association['id']);
        Assert::notNull($association['Handler']);

        $association['core:IdP'] = $this->id;

        $session = Session::getSessionFromRequest();
        $session->addAssociation($this->associationGroup, $association);
    }


    /**
     * Retrieve list of SP associations.
     *
     * @return array List of SP associations.
     */
    public function getAssociations(): array
    {
        $session = Session::getSessionFromRequest();
        return $session->getAssociations($this->associationGroup);
    }


    /**
     * Remove an SP association.
     *
     * @param string $assocId The association id.
     */
    public function terminateAssociation(string $assocId): void
    {
        $session = Session::getSessionFromRequest();
        $session->terminateAssociation($this->associationGroup, $assocId);
    }


    /**
     * Is the current user authenticated?
     *
     * @return boolean True if the user is authenticated, false otherwise.
     */
    public function isAuthenticated(): bool
    {
        return $this->authSource->isAuthenticated();
    }


    /**
     * Called after authproc has run.
     *
     * @param array $state The authentication request state array.
     */
    public static function postAuthProc(array $state): void
    {
        Assert::isCallable($state['Responder']);

        if (isset($state['core:SP'])) {
            $session = Session::getSessionFromRequest();
            $session->setData(
                'core:idp-ssotime',
                $state['core:IdP'] . ';' . $state['core:SP'],
                time(),
                Session::DATA_TIMEOUT_SESSION_END,
            );
        }

        call_user_func($state['Responder'], $state);
        Assert::true(false);
    }


    /**
     * The user is authenticated.
     *
     * @param array $state The authentication request state array.
     *
     * @throws \SimpleSAML\Error\Exception If we are not authenticated.
     */
    public static function postAuth(array $state): void
    {
        $idp = IdP::getByState($state);

        if (!$idp->isAuthenticated()) {
            throw new Error\Exception('Not authenticated.');
        }

        $state['Attributes'] = $idp->authSource->getAttributes();

        if (isset($state['SPMetadata'])) {
            $spMetadata = $state['SPMetadata'];
        } else {
            $spMetadata = [];
        }

        if (isset($state['core:SP'])) {
            $session = Session::getSessionFromRequest();
            $previousSSOTime = $session->getData('core:idp-ssotime', $state['core:IdP'] . ';' . $state['core:SP']);
            if ($previousSSOTime !== null) {
                $state['PreviousSSOTimestamp'] = $previousSSOTime;
            }
        }

        $idpMetadata = $idp->getConfig()->toArray();

        $pc = new Auth\ProcessingChain($idpMetadata, $spMetadata, 'idp');

        $state['ReturnCall'] = ['\SimpleSAML\IdP', 'postAuthProc'];
        $state['Destination'] = $spMetadata;
        $state['Source'] = $idpMetadata;

        $pc->processState($state);

        self::postAuthProc($state);
    }


    /**
     * Authenticate the user.
     *
     * This function authenticates the user.
     *
     * @param array &$state The authentication request state.
     *
     * @throws \SimpleSAML\Module\saml\Error\NoPassive If we were asked to do passive authentication.
     */
    private function authenticate(array &$state): void
    {
        if (isset($state['isPassive']) && (bool) $state['isPassive']) {
            throw new NoPassiveException(Constants::STATUS_RESPONDER . ':  Passive authentication not supported.');
        }

        $this->authSource->login($state);
    }


    /**
     * Re-authenticate the user.
     *
     * This function re-authenticates an user with an existing session. This gives the authentication source a chance
     * to do additional work when re-authenticating for SSO.
     *
     * Note: This function is not used when ForceAuthn=true.
     *
     * @param array &$state The authentication request state.
     *
     * @throws \Exception If there is no auth source defined for this IdP.
     */
    private function reauthenticate(array &$state): void
    {
        $sourceImpl = $this->authSource->getAuthSource();
        $sourceImpl->reauthenticate($state);
    }


    /**
     * Process authentication requests.
     *
     * @param array &$state The authentication request state.
     */
    public function handleAuthenticationRequest(array &$state): void
    {
        Assert::notNull($state['Responder']);

        $state['core:IdP'] = $this->id;

        if (isset($state['SPMetadata']['entityid'])) {
            $spEntityId = $state['SPMetadata']['entityid'];
        } elseif (isset($state['SPMetadata']['entityID'])) {
            $spEntityId = $state['SPMetadata']['entityID'];
        } else {
            $spEntityId = null;
        }
        $state['core:SP'] = $spEntityId;

        // first, check whether we need to authenticate the user
        if (isset($state['ForceAuthn']) && (bool) $state['ForceAuthn']) {
            // force authentication is in effect
            $needAuth = true;
        } else {
            $needAuth = !$this->isAuthenticated();
        }

        $state['IdPMetadata'] = $this->getConfig()->toArray();
        $state['ReturnCallback'] = ['\SimpleSAML\IdP', 'postAuth'];

        try {
            if ($needAuth) {
                $this->authenticate($state);
                Assert::true(false);
            } else {
                $this->reauthenticate($state);
            }
            $this->postAuth($state);
        } catch (Error\Exception $e) {
            Auth\State::throwException($state, $e);
        } catch (\Exception $e) {
            $e = new Error\UnserializableException($e);
            Auth\State::throwException($state, $e);
        }
    }


    /**
     * Find the logout handler of this IdP.
     *
     * @return \SimpleSAML\IdP\LogoutHandlerInterface The logout handler class.
     *
     * @throws \Exception If we cannot find a logout handler.
     */
    public function getLogoutHandler(): LogoutHandlerInterface
    {
        // find the logout handler
        $logouttype = $this->getConfig()->getOptionalString('logouttype', 'traditional');
        switch ($logouttype) {
            case 'traditional':
                $handler = TraditionalLogoutHandler::class;
                break;
            case 'iframe':
                $handler = IFrameLogoutHandler::class;
                break;
            default:
                throw new Error\Exception('Unknown logout handler: ' . var_export($logouttype, true));
        }

        /** @var \SimpleSAML\IdP\LogoutHandlerInterface */
        return new $handler($this);
    }


    /**
     * Finish the logout operation.
     *
     * This function will never return.
     *
     * @param array &$state The logout request state.
     */
    public function finishLogout(array &$state): void
    {
        Assert::notNull($state['Responder']);

        $idp = IdP::getByState($state);
        call_user_func($state['Responder'], $idp, $state);
        Assert::true(false);
    }


    /**
     * Process a logout request.
     *
     * This function will never return.
     *
     * @param array       &$state The logout request state.
     * @param string|null $assocId The association we received the logout request from, or null if there was no
     * association.
     */
    public function handleLogoutRequest(array &$state, ?string $assocId): void
    {
        Assert::notNull($state['Responder']);
        Assert::nullOrString($assocId);

        $state['core:IdP'] = $this->id;
        $state['core:TerminatedAssocId'] = $assocId;

        if ($assocId !== null) {
            $this->terminateAssociation($assocId);
            $session = Session::getSessionFromRequest();
            $session->deleteData('core:idp-ssotime', $this->id . ';' . $state['saml:SPEntityId']);
        }

        // terminate the local session
        $id = Auth\State::saveState($state, 'core:Logout:afterbridge');
        $returnTo = Module::getModuleURL('core/logout-resume', ['id' => $id]);

        $this->authSource->logout($returnTo);

        if ($assocId !== null) {
            $handler = $this->getLogoutHandler();
            $handler->startLogout($state, $assocId);
        }
        Assert::true(false);
    }


    /**
     * Process a logout response.
     *
     * This function will never return.
     *
     * @param string                 $assocId The association that is terminated.
     * @param string|null            $relayState The RelayState from the start of the logout.
     * @param \SimpleSAML\Error\Exception|null $error  The error that occurred during session termination (if any).
     */
    public function handleLogoutResponse(string $assocId, ?string $relayState, ?Error\Exception $error = null): void
    {
        $index = strpos($assocId, ':');
        Assert::integer($index);

        $session = Session::getSessionFromRequest();
        $session->deleteData('core:idp-ssotime', $this->id . ';' . substr($assocId, $index + 1));

        $handler = $this->getLogoutHandler();
        $handler->onResponse($assocId, $relayState, $error);
    }


    /**
     * Log out, then redirect to a URL.
     *
     * This function never returns.
     *
     * @param string $url The URL the user should be returned to after logout.
     */
    public function doLogoutRedirect(string $url): void
    {
        $state = [
            'Responder'       => [IdP::class, 'finishLogoutRedirect'],
            'core:Logout:URL' => $url,
        ];

        $this->handleLogoutRequest($state, null);
        Assert::true(false);
    }


    /**
     * Redirect to a URL after logout.
     *
     * This function never returns.
     *
     * @param IdP      $idp Deprecated. Will be removed.
     * @param array    &$state The logout state from doLogoutRedirect().
     */
    public static function finishLogoutRedirect(IdP $idp, array $state): void
    {
        Assert::notNull($state['core:Logout:URL']);

        $httpUtils = new Utils\HTTP();
        $httpUtils->redirectTrustedURL($state['core:Logout:URL']);
        Assert::true(false);
    }
}
