<?php

namespace SimpleSAML;

/**
 * Class from simplesamlphps session
 */

class Session implements \Serializable
{
    /**
     * This is a timeout value for setData, which indicates that the data
     * should never be deleted, i.e. lasts the whole session lifetime.
     */
    const DATA_TIMEOUT_SESSION_END = 'sessionEndTimeout';

    /**
     * The list of loaded session objects.
     *
     * This is an associative array indexed with the session id.
     *
     * @var array
     */
    private static $sessions = [];


    /**
     * This variable holds the instance of the session - Singleton approach.
     *
     * Warning: do not set the instance manually, call Session::load() instead.
     */
    private static $instance = null;

    /**
     * The global configuration.
     *
     * @var \SimpleSAML\Configuration
     */
    private static $config;

    /**
     * The session ID of this session.
     *
     * @var string|null
     */
    private $sessionId;

    /**
     * Transient session flag.
     *
     * @var boolean|false
     */
    private $transient = false;

    /**
     * The track id is a new random unique identifier that is generated for each session.
     * This is used in the debug logs and error messages to easily track more information
     * about what went wrong.
     *
     * @var string|null
     */
    private $trackid = null;

    /**
     * @var integer|null
     */
    private $rememberMeExpire = null;

    /**
     * Marks a session as modified, and therefore needs to be saved before destroying
     * this object.
     *
     * @var bool
     */
    private $dirty = false;

    /**
     * Tells the session object that the save callback has been registered and there's no need to register it again.
     *
     * @var bool
     */
    private $callback_registered = false;

    /**
     * This is an array of objects which will expire automatically after a set time. It is used
     * where one needs to store some information - for example a logout request, but doesn't
     * want it to be stored forever.
     *
     * The data store contains three levels of nested associative arrays. The first is the data type, the
     * second is the identifier, and the third contains the expire time of the data and the data itself.
     *
     * @var array
     */
    private $dataStore = [];

    /**
     * The list of IdP-SP associations.
     *
     * This is an associative array with the IdP id as the key, and the list of
     * associations as the value.
     *
     * @var array
     */
    private $associations = [];

    /**
     * The authentication token.
     *
     * This token is used to prevent session fixation attacks.
     *
     * @var string|null
     */
    private $authToken;

    /**
     * Authentication data.
     *
     * This is an array with authentication data for the various authsources.
     *
     * @var array  Associative array of associative arrays.
     */
    private $authData = [];

    /**
     * Retrieve a single attribute.
     *
     * @param string $name  The name of the attribute.
     * @return array|null  The values of the given attribute.
     */
    public function getAttribute($name)
    {
        foreach ($this->authData as $data) {
            if (isset($data['Attributes'][$name])) {
                return $data['Attributes'][$name];
            }
        }
        return null;
    }

    /**
     * Serialize this session object.
     *
     * This method will be invoked by any calls to serialize().
     *
     * @return string The serialized representation of this session object.
     */
    public function serialize()
    {
        return serialize(get_object_vars($this));
    }

    /**
     * Unserialize a session object and load it..
     *
     * This method will be invoked by any calls to unserialize(), allowing us to restore any data that might not
     * be serializable in its original form (e.g.: DOM objects).
     *
     * @param string $serialized The serialized representation of a session that we want to restore.
     */
    public function unserialize($serialized)
    {
        $session = unserialize($serialized);
        if (is_array($session)) {
            foreach ($session as $k => $v) {
                $this->$k = $v;
            }
        }

        // look for any raw attributes and load them in the 'Attributes' array
        foreach ($this->authData as $authority => $parameters) {
            if (!array_key_exists('RawAttributes', $parameters)) {
                continue;
            }

            foreach ($parameters['RawAttributes'] as $attribute => $values) {
                foreach ($values as $idx => $value) {
                    // this should be originally a DOMNodeList
                    /* @var \SAML2\XML\saml\AttributeValue $value */
                    $this->authData[$authority]['Attributes'][$attribute][$idx] = $value->element->childNodes;
                }
            }
        }
    }

    /**
     * Retrieve the session ID of this session.
     *
     * @return string|null  The session ID, or null if this is a transient session.
     */
    public function getSessionId()
    {
        return $this->sessionId;
    }

    /**
     * Retrieve if session is transient.
     *
     * @return boolean The session transient flag.
     */
    public function isTransient()
    {
        return $this->transient;
    }

    /**
     * Get a unique ID that will be permanent for this session.
     * Used for debugging and tracing log files related to a session.
     *
     * @return string|null The unique ID.
     */
    public function getTrackID()
    {
        return $this->trackid;
    }

    /**
     * Get remember me expire time.
     *
     * @return integer|null The remember me expire time.
     */
    public function getRememberMeExpire()
    {
        return $this->rememberMeExpire;
    }

    /**
     * Is the session representing an authenticated user, and is the session still alive.
     * This function will return false after the user has timed out.
     *
     * @param string $authority The authentication source that the user should be authenticated with.
     *
     * @return bool True if the user has a valid session, false if not.
     */
    public function isValid($authority)
    {
        assert(is_string($authority));

        if (!isset($this->authData[$authority])) {
            Logger::debug(
                'Session: '.var_export($authority, true).
                ' not valid because we are not authenticated.'
            );
            return false;
        }

        if ($this->authData[$authority]['Expire'] <= time()) {
            Logger::debug('Session: '.var_export($authority, true).' not valid because it is expired.');
            return false;
        }

        Logger::debug('Session: Valid session found with '.var_export($authority, true).'.');

        return true;
    }

    /**
     * This function removes expired data from the data store.
     *
     * Note that this function doesn't mark the session object as dirty. This means that
     * if the only change to the session object is that some data has expired, it will not be
     * written back to the session store.
     */
    private function expireData()
    {
        $ct = time();

        foreach ($this->dataStore as &$typedData) {
            foreach ($typedData as $id => $info) {
                if ($info['expires'] === self::DATA_TIMEOUT_SESSION_END) {
                    // this data never expires
                    continue;
                }

                if ($ct > $info['expires']) {
                    unset($typedData[$id]);
                }
            }
        }
    }

    /**
     * This function retrieves data from the data store.
     *
     * Note that this will not change when the data stored in the data store will expire. If that is required,
     * the data should be written back with setData.
     *
     * @param string      $type The type of the data. This must match the type used when adding the data.
     * @param string|null $id The identifier of the data. Can be null, in which case null will be returned.
     *
     * @return mixed The data of the given type with the given id or null if the data doesn't exist in the data store.
     */
    public function getData($type, $id)
    {
        assert(is_string($type));
        assert($id === null || is_string($id));

        if ($id === null) {
            return null;
        }

        $this->expireData();

        if (!array_key_exists($type, $this->dataStore)) {
            return null;
        }

        if (!array_key_exists($id, $this->dataStore[$type])) {
            return null;
        }

        return $this->dataStore[$type][$id]['data'];
    }

    /**
     * This function retrieves all data of the specified type from the data store.
     *
     * The data will be returned as an associative array with the id of the data as the key, and the data
     * as the value of each key. The value will be stored as a copy of the original data. setData must be
     * used to update the data.
     *
     * An empty array will be returned if no data of the given type is found.
     *
     * @param string $type The type of the data.
     *
     * @return array An associative array with all data of the given type.
     */
    public function getDataOfType($type)
    {
        assert(is_string($type));

        if (!array_key_exists($type, $this->dataStore)) {
            return [];
        }

        $ret = [];
        foreach ($this->dataStore[$type] as $id => $info) {
            $ret[$id] = $info['data'];
        }

        return $ret;
    }

    /**
     * Get the current persistent authentication state.
     *
     * @param string $authority The authority to retrieve the data from.
     *
     * @return array  The current persistent authentication state, or null if not authenticated.
     */
    public function getAuthState($authority)
    {
        assert(is_string($authority));

        if (!isset($this->authData[$authority])) {
            return null;
        }

        return $this->authData[$authority];
    }

    /**
     * Check whether the session cookie is set.
     *
     * This function will only return false if is is certain that the cookie isn't set.
     *
     * @return bool  true if it was set, false if not.
     */
    public function hasSessionCookie()
    {
        $sh = SessionHandler::getSessionHandler();
        return $sh->hasSessionCookie();
    }

    /**
     * Retrieve the associations for an IdP.
     *
     * This function is only for use by the IdP class.
     *
     * @param string $idp The IdP id.
     *
     * @return array  The IdP associations.
     */
    public function getAssociations($idp)
    {
        assert(is_string($idp));

        if (!isset($this->associations)) {
            $this->associations = [];
        }

        if (!isset($this->associations[$idp])) {
            return [];
        }

        foreach ($this->associations[$idp] as $id => $assoc) {
            if (!isset($assoc['Expires'])) {
                continue;
            }
            if ($assoc['Expires'] >= time()) {
                continue;
            }

            unset($this->associations[$idp][$id]);
        }

        return $this->associations[$idp];
    }

    /**
     * Retrieve authentication data.
     *
     * @param string $authority The authentication source we should retrieve data from.
     * @param string $name The name of the data we should retrieve.
     *
     * @return mixed  The value, or null if the value wasn't found.
     */
    public function getAuthData($authority, $name)
    {
        assert(is_string($authority));
        assert(is_string($name));

        if (!isset($this->authData[$authority][$name])) {
            return null;
        }
        return $this->authData[$authority][$name];
    }

    /**
     * Retrieve a list of authorities (authentication sources) that are currently valid within
     * this session.
     *
     * @return mixed An array containing every authority currently valid. Empty if none available.
     */
    public function getAuthorities()
    {
        $authorities = [];
        foreach (array_keys($this->authData) as $authority) {
            if ($this->isValid($authority)) {
                $authorities[] = $authority;
            }
        }
        return $authorities;
    }
}
