<?php

namespace iDServer;


use DomainException;
use Exception;
use iDServer\Exception\iDServerException;
use iDServer\Internal\Crypto;
use DOMDocument;
use iDServer\Message\AttributeQuery;
use iDServer\Message\Login;
use iDServer\Message\Logout;
use iDServer\Message\LogoutResponse;
use iDServer\Message\AbstractMessage;
use iDServer\Message\MessageType;
use iDServer\Message\NameIdMapping;
use iDServer\Message\Strategy\MixedMessageHandlingStrategy;
use iDServer\Saml\AttributeQueryData;
use iDServer\Saml\AuthnRequestData;
use iDServer\Saml\LogoutRequestData;
use iDServer\Saml\NameId;
use iDServer\Saml\NameIdMappingData;
use InvalidArgumentException;
use iDServer\Internal\Redirector;
use iDServer\AbstractKeyStore;

/**
 * An iDServer service provider.
 *
 * You can use this class as it is, but it's better to subclass it. You can augment it with
 * additional behaviour by overriding the onSend() and onReceiveMessage() methods. As it is, it really only provides the
 * cryptographic functionalities necessary for an SP to send and receive messages.
 */
class iDServer
{
    /**
     * @var string
     */
    private $serverUrl;

    /**
     * @var null|string
     */
    private $defaultLanguage = null;

    /**
     * @var AbstractKeyStore
     */
    private $keyStore;

    /**
     * @var string
     */
    private $name;

    /**
     * iDServer constructor
     *
     * @param string $serverUrl The URL of the iDServer Proxy
     * @param string $name The name of this client
     */
    public function __construct(string $serverUrl, string $name)
    {
        if (!filter_var($serverUrl, FILTER_VALIDATE_URL)) {
            throw new InvalidArgumentException("Invalid URL");
        }

        $this->serverUrl = $serverUrl;
        $this->name = $name;
    }

    /**
     * Get server URL of the iDServer proxy
     *
     * @return string
     */
    final public function getServerUrl(): string
    {
        return $this->serverUrl;
    }

    /**
     * Get the name of the client
     *
     * @return string
     */
    final public function getName(): string
    {
        return $this->name;
    }

    /**
     * Set the keyStore used by this client.
     *
     * @param AbstractKeyStore $store
     * @return $this
     */
    final public function setKeyStore(AbstractKeyStore $store)
    {
        $this->keyStore = $store;
        return $this;
    }

    /**
     * @return AbstractKeyStore
     */
    final public function getKeyStore(): AbstractKeyStore
    {
        return $this->keyStore;
    }

    /**
     * Set the default language this client uses.
     *
     * @param string $language Two-letter code of the language.
     */
    final public function setDefaultLanguage(string $language)
    {
        $this->defaultLanguage = $language;
    }

    /**
     * @return string|null
     */
    final public function getDefaultLanguage(): ?string
    {
        return $this->defaultLanguage;
    }


    /**
     * @param AbstractMessage $message
     * @return array
     * @throws Exception
     */
    private function prepareRequest(AbstractMessage $message): array
    {
        $message->getData()->setIssuer($this->getName());

        Log::debug("Sending message...");
        // todo: other strategies
        $doc = $message->getSignedXml(new MixedMessageHandlingStrategy());

        $lang = empty($message->getLanguage()) ? $this->getDefaultLanguage() : $message->getLanguage();

        $params = ['SAMLRequest' => base64_encode($doc->saveXML())];
        if (isset($lang)) {
            $params['lang'] = $lang;
        }
        Log::debug("Language is set to " . $lang);

        $this->onSend($message->getType(), $doc);
        return $params;
    }

    /**
     * @param AbstractMessage $message
     * @return array
     * @throws iDServerException
     * @throws Exception
     */
    private function send(AbstractMessage $message): array
    {
        $params = $this->prepareRequest($message);

        $curl = curl_init();

        curl_setopt($curl, CURLOPT_URL, $message->getData()->getDestination());
        curl_setopt($curl, CURLOPT_POST, count($params));
        curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($params));
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_HTTP_VERSION, '1.0');
        curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, false);
        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);

        $result = curl_exec($curl);

        curl_close($curl);

        return $this->receiveMessage($result, $message->getType());
    }

    /**
     * @param AbstractMessage $message
     * @throws Exception
     */
    private function postRedirect(AbstractMessage $message)
    {
        $params = $this->prepareRequest($message);
        $redir = new Redirector($this->serverUrl);
        $redir->post($message->getData()->getDestination(), $params);
    }

    /**
     * Override this if you would like to add behaviour for send().
     *
     * This method will be called right before sending the message.
     * You are supplied with the xml document about to be sent as a parameter.
     *
     * @param mixed $type The MessageType of the message to be sent.
     *                    Either {@link MessageType::LOGIN} or {@link MessageType::LOGOUT}
     * @param DOMDocument $doc The XML representation of the message about to be sent.
     *
     * @return void
     */
    public function onSend($type, DOMDocument $doc)
    {
    }


    /**
     * receive an SAML reply.
     *
     * @param string $resp The base64 encoded reply as got from $_POST['SAMLResponse'] or $_POST['SAMLRequest'].
     * @param string $type The type of the response, as defined in \iDServer\MessageType.
     *
     * @return array An associative array describing the result of the action.
     *
     * @throws iDServerException
     * @throws Exception
     */
    final public function receiveMessage(string $resp, string $type): array
    {
        $resp = base64_decode(trim($resp));
        $doc = new DOMDocument();
        $doc->loadXML($resp);

        $id = $doc->documentElement->getAttribute('ID');
        $reqId = $doc->documentElement->getAttribute('InResponseTo');
        Log::debug("received " . $type
            . " SAML message " . $id
            . " in response to " . $reqId
            . ": \n" . $doc->saveXML());

        $issuer = trim($doc->getElementsByTagName('Issuer')->item(0)->textContent);
        $certs = $this->keyStore->getCertificate($issuer);
        Crypto\VerifySamlWithFallbacks($doc, $certs);

        $decryptableElements = [
            'EncryptedAttribute',
            'EncryptedAssertion',
        ];

        foreach ($decryptableElements as $decryptableElement) {
            Log::debug('Decrypting ' . $decryptableElement . ' elements');
            Crypto\decryptNodeList($doc->getElementsByTagName($decryptableElement), $this->keyStore->getCryptKey());
        }

        Log::debug("Message " . $id . " decrypted");

        switch ($type) {
            case MessageType::LOGIN_REPLY:
                $result = Login::receiveMessage($doc);
                break;
            case MessageType::LOGOUT_REPLY:
                $result = Logout::receiveMessage($doc);
                break;
            case MessageType::LOGOUT:
                $result = LogoutResponse::receiveRequest($this->getKeyStore(), $doc);
                break;
            case MessageType::ATTRIBUTE_QUERY:
                $result = AttributeQuery::receiveMessage($doc);
                break;
            case MessageType::NAME_ID_MAPPING:
                $result = NameIdMapping::receiveMessage($doc);
                break;
            default:
                $msg = "Received SAMLResponse message with unknown type: " . $type . ". This should not happen.";
                Log::error($msg);
                throw new DomainException($msg);
        }

        $this->onReceiveMessage($type, $result);

        return $result;
    }

    /**
     * Override this function to add functionality to receiveMessage.
     *
     * This is called right before returning the $result array, which
     * you will be given to modify as you see fit.
     *
     * @param string $type The MessageType of the received reply.
     *                     Either {@link MessageType::LoginReply} or {@link MessageType::LogoutReply}
     * @param array& $result The parsed and processed reply data, passed as a reference so that you can modify it.
     *
     * @return void
     */
    public function onReceiveMessage($type, &$result)
    {
    }

    /**
     * @param AuthnRequestData $data
     * @param string $language
     * @throws Exception
     */
    final public function login(AuthnRequestData $data, string $language)
    {
        $login = new Login($this->getKeyStore(), $this->getServerUrl(), $data);
        $login->setLanguage($language);
        $this->postRedirect($login);
    }

    /**
     * @param string $token
     * @param string $sessionIndex
     * @throws Exception
     */
    final public function logout(string $token, string $sessionIndex)
    {
        $data = new LogoutRequestData();
        $logout = new Logout($this->getKeyStore(), $this->getServerUrl(), $data);

        $data->setNameId(new NameId($token, $this->getName()));
        $data->setSessionIndex($sessionIndex);

        $this->postRedirect($logout);
    }

    /**
     * @param NameIdMappingData $data
     * @return array
     * @throws Exception
     */
    final public function nameIdMapping(NameIdMappingData $data): array
    {
        $nameIdMapping = new NameIdMapping($this->getKeyStore(), $this->getServerUrl(), $data);
        return $this->send($nameIdMapping);
    }

    /**
     * @param AttributeQueryData $data
     * @return array
     * @throws Exception
     */
    final public function attributeQuery(AttributeQueryData $data): array
    {
        $attributeQuery = new AttributeQuery($this->getKeyStore(), $this->getServerUrl(), $data);
        return $this->send($attributeQuery);
    }
}
