# -*- coding: utf-8 -*-
# Copyright 2011 amanwa32 AUTHORS.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
amanwa32_extras.sessions
=======================

Lightweight but flexible session support for amanwa32.
"""
import re

import six
import amanwa32

from amanwa32_extras import securecookie
from amanwa32_extras import security

#: Default configuration values for this module. Keys are:
#:
#: secret_key
#:     Secret key to generate session cookies. Set this to something random
#:     and unguessable. This is the only required configuration key:
#:     an exception is raised if it is not defined.
#:
#: cookie_name
#:     Name of the cookie to save a session or session id. Default is
#:     `session`.
#:
#: session_max_age:
#:     Default session expiration time in seconds. Limits the duration of the
#:     contents of a cookie, even if a session cookie exists. If None, the
#:     contents lasts as long as the cookie is valid. Default is None.
#:
#: cookie_args
#:     Default keyword arguments used to set a cookie. Keys are:
#:
#:     - max_age: Cookie max age in seconds. Limits the duration
#:       of a session cookie. If None, the cookie lasts until the client
#:       is closed. Default is None.
#:
#:     - domain: Domain of the cookie. To work accross subdomains the
#:       domain must be set to the main domain with a preceding dot, e.g.,
#:       cookies set for `.mydomain.org` will work in `foo.mydomain.org` and
#:       `bar.mydomain.org`. Default is None, which means that cookies will
#:       only work for the current subdomain.
#:
#:     - path: Path in which the authentication cookie is valid.
#:       Default is `/`.
#:
#:     - secure: Make the cookie only available via HTTPS.
#:
#:     - httponly: Disallow JavaScript to access the cookie.
#:
#: backends
#:     A dictionary of available session backend classes used by
#:     :meth:`SessionStore.get_session`.
default_config = {
    'secret_key':      None,
    'cookie_name':     'session',
    'session_max_age': None,
    'cookie_args': {
        'max_age':     None,
        'domain':      None,
        'path':        '/',
        'secure':      None,
        'httponly':    False,
    },
    'backends': {
        'securecookie': 'amanwa32_extras.sessions.SecureCookieSessionFactory',
        'datastore':    'amanwa32_extras.sessions.DatastoreSessionFactory',
        'memcache':     'amanwa32_extras.sessions_memcache.MemcacheSessionFactory',
    },
}

_default_value = object()


class _UpdateDictMixin(object):
    """Makes dicts call `self.on_update` on modifications.

    From werkzeug.datastructures.
    """

    on_update = None

    def calls_update(name):
        def oncall(self, *args, **kw):
            rv = getattr(super(_UpdateDictMixin, self), name)(*args, **kw)
            if self.on_update is not None:
                self.on_update()
            return rv
        oncall.__name__ = name
        return oncall

    __setitem__ = calls_update('__setitem__')
    __delitem__ = calls_update('__delitem__')
    clear = calls_update('clear')
    pop = calls_update('pop')
    popitem = calls_update('popitem')
    setdefault = calls_update('setdefault')
    update = calls_update('update')
    del calls_update


class SessionDict(_UpdateDictMixin, dict):
    """A dictionary for session data."""

    __slots__ = ('container', 'new', 'modified')

    def __init__(self, container, data=None, new=False):
        self.container = container
        self.new = new
        self.modified = False
        dict.update(self, data or ())

    def pop(self, key, *args):
        # Only pop if key doesn't exist, do not alter the dictionary.
        if key in self:
            return super(SessionDict, self).pop(key, *args)
        if args:
            return args[0]
        raise KeyError(key)

    def on_update(self):
        self.modified = True

    def get_flashes(self, key='_flash'):
        """Returns a flash message. Flash messages are deleted when first read.

        :param key:
            Name of the flash key stored in the session. Default is '_flash'.
        :returns:
            The data stored in the flash, or an empty list.
        """
        return self.pop(key, [])

    def add_flash(self, value, level=None, key='_flash'):
        """Adds a flash message. Flash messages are deleted when first read.

        :param value:
            Value to be saved in the flash message.
        :param level:
            An optional level to set with the message. Default is `None`.
        :param key:
            Name of the flash key stored in the session. Default is '_flash'.
        """
        self.setdefault(key, []).append((value, level))


class BaseSessionFactory(object):
    """Base class for all session factories."""

    #: Name of the session.
    name = None
    #: A reference to :class:`SessionStore`.
    session_store = None
    #: Keyword arguments to save the session.
    session_args = None
    #: The session data, a :class:`SessionDict` instance.
    session = None

    def __init__(self, name, session_store):
        self.name = name
        self.session_store = session_store
        self.session_args = session_store.config['cookie_args'].copy()
        self.session = None

    def get_session(self, max_age=_default_value):
        raise NotImplementedError()

    def save_session(self, response):
        raise NotImplementedError()


class SecureCookieSessionFactory(BaseSessionFactory):
    """A session factory that stores data serialized in a signed cookie.

    Signed cookies can't be forged because the HMAC signature won't match.

    This is the default factory passed as the `factory` keyword to
    :meth:`SessionStore.get_session`.

    .. warning::
       The values stored in a signed cookie will be visible in the cookie,
       so do not use secure cookie sessions if you need to store data that
       can't be visible to users. For this, use datastore or memcache sessions.
    """

    def get_session(self, max_age=_default_value):
        if self.session is None:
            data = self.session_store.get_secure_cookie(self.name,
                                                        max_age=max_age)
            new = data is None
            self.session = SessionDict(self, data=data, new=new)

        return self.session

    def save_session(self, response):
        if self.session is None or not self.session.modified:
            return

        self.session_store.save_secure_cookie(
            response, self.name, dict(self.session), **self.session_args)


class CustomBackendSessionFactory(BaseSessionFactory):
    """Base class for sessions that use custom backends, e.g., memcache."""

    #: The session unique id.
    sid = None

    #: Used to validate session ids.
    _sid_re = re.compile(r'^\w{22}$')

    def get_session(self, max_age=_default_value):
        if self.session is None:
            data = self.session_store.get_secure_cookie(self.name,
                                                        max_age=max_age)
            sid = data.get('_sid') if data else None
            self.session = self._get_by_sid(sid)

        return self.session

    def _get_by_sid(self, sid):
        raise NotImplementedError()

    def _is_valid_sid(self, sid):
        """Check if a session id has the correct format."""
        return sid and self._sid_re.match(sid) is not None

    def _get_new_sid(self):
        return security.generate_random_string(entropy=128)


class SessionStore(object):
    """A session provider for a single request.

    The session store can provide multiple sessions using different keys,
    even using different backends in the same request, through the method
    :meth:`get_session`. By default it returns a session using the default key.

    To use, define a base handler that extends the dispatch() method to start
    the session store and save all sessions at the end of a request::

        import amanwa32

        from amanwa32_extras import sessions

        class BaseHandler(amanwa32.RequestHandler):
            def dispatch(self):
                # Get a session store for this request.
                self.session_store = sessions.get_store(request=self.request)

                try:
                    # Dispatch the request.
                    amanwa32.RequestHandler.dispatch(self)
                finally:
                    # Save all sessions.
                    self.session_store.save_sessions(self.response)

            @amanwa32.cached_property
            def session(self):
                # Returns a session using the default cookie key.
                return self.session_store.get_session()

    Then just use the session as a dictionary inside a handler::

        # To set a value:
        self.session['foo'] = 'bar'

        # To get a value:
        foo = self.session.get('foo')

    A configuration dict can be passed to :meth:`__init__`, or the application
    must be initialized with the ``secret_key`` configuration defined. The
    configuration is a simple dictionary::

        config = {}
        config['amanwa32_extras.sessions'] = {
            'secret_key': 'my-super-secret-key',
        }

        app = amanwa32.WSGIApplication([
            ('/', HomeHandler),
        ], config=config)

    Other configuration keys are optional.
    """

    #: Configuration key.
    config_key = __name__

    def __init__(self, request, config=None):
        """Initializes the session store.

        :param request:
            A :class:`amanwa32.Request` instance.
        :param config:
            A dictionary of configuration values to be overridden. See
            the available keys in :data:`default_config`.
        """
        self.request = request
        # Base configuration.
        self.config = request.app.config.load_config(
            self.config_key,
            default_values=default_config,
            user_values=config,
            required_keys=('secret_key',)
        )
        # Tracked sessions.
        self.sessions = {}

    @amanwa32.cached_property
    def serializer(self):
        # Serializer and deserializer for signed cookies.
        return securecookie.SecureCookieSerializer(self.config['secret_key'])

    def get_backend(self, name):
        """Returns a configured session backend, importing it if needed.

        :param name:
            The backend keyword.
        :returns:
            A :class:`BaseSessionFactory` subclass.
        """
        backends = self.config['backends']
        backend = backends[name]
        if isinstance(backend, six.string_types):
            backend = backends[name] = amanwa32.import_string(backend)

        return backend

    # Backend based sessions --------------------------------------------------

    def _get_session_container(self, name, factory):
        if name not in self.sessions:
            self.sessions[name] = factory(name, self)

        return self.sessions[name]

    def get_session(self, name=None, max_age=_default_value, factory=None,
                    backend='securecookie'):
        """Returns a session for a given name. If the session doesn't exist, a
        new session is returned.

        :param name:
            Cookie name. If not provided, uses the ``cookie_name``
            value configured for this module.
        :param max_age:
            A maximum age in seconds for the session to be valid. Sessions
            store a timestamp to invalidate them if needed. If `max_age` is
            None, the timestamp won't be checked.
        :param factory:
            A session factory that creates the session using the preferred
            backend. For convenience, use the `backend` argument instead,
            which defines a backend keyword based on the configured ones.
        :param backend:
            A configured backend keyword. Available ones are:

            - ``securecookie``: uses secure cookies. This is the default
              backend.
            - ``datastore``: uses App Engine's datastore.
            - ``memcache``:  uses App Engine's memcache.
        :returns:
            A dictionary-like session object.
        """
        factory = factory or self.get_backend(backend)
        name = name or self.config['cookie_name']

        if max_age is _default_value:
            max_age = self.config['session_max_age']

        container = self._get_session_container(name, factory)
        return container.get_session(max_age=max_age)

    # Signed cookies ----------------------------------------------------------

    def get_secure_cookie(self, name, max_age=_default_value):
        """Returns a deserialized secure cookie value.

        :param name:
            Cookie name.
        :param max_age:
            Maximum age in seconds for a valid cookie. If the cookie is older
            than this, returns None.
        :returns:
            A secure cookie value or None if it is not set.
        """
        if max_age is _default_value:
            max_age = self.config['session_max_age']

        value = self.request.cookies.get(name)
        if value:
            return self.serializer.deserialize(name, value, max_age=max_age)

    def set_secure_cookie(self, name, value, **kwargs):
        """Sets a secure cookie to be saved.

        :param name:
            Cookie name.
        :param value:
            Cookie value. Must be a dictionary.
        :param kwargs:
            Options to save the cookie. See :meth:`get_session`.
        """
        assert isinstance(value, dict), 'Secure cookie values must be a dict.'
        container = self._get_session_container(name,
                                                SecureCookieSessionFactory)
        container.get_session().update(value)
        container.session_args.update(kwargs)

    # Saving to a response object ---------------------------------------------

    def save_sessions(self, response):
        """Saves all sessions in a response object.

        :param response:
            A :class:`webapp.Response` object.
        """
        for session in self.sessions.values():
            session.save_session(response)

    def save_secure_cookie(self, response, name, value, **kwargs):
        value = self.serializer.serialize(name, value)
        response.set_cookie(name, value, **kwargs)


# Factories -------------------------------------------------------------------


#: Key used to store :class:`SessionStore` in the request registry.
_registry_key = 'amanwa32_extras.sessions.SessionStore'


def get_store(factory=SessionStore, key=_registry_key, request=None):
    """Returns an instance of :class:`SessionStore` from the request registry.

    It'll try to get it from the current request registry, and if it is not
    registered it'll be instantiated and registered. A second call to this
    function will return the same instance.

    :param factory:
        The callable used to build and register the instance if it is not yet
        registered. The default is the class :class:`SessionStore` itself.
    :param key:
        The key used to store the instance in the registry. A default is used
        if it is not set.
    :param request:
        A :class:`amanwa32.Request` instance used to store the instance. The
        active request is used if it is not set.
    """
    request = request or amanwa32.get_request()
    store = request.registry.get(key)
    if not store:
        store = request.registry[key] = factory(request)

    return store


def set_store(store, key=_registry_key, request=None):
    """Sets an instance of :class:`SessionStore` in the request registry.

    :param store:
        An instance of :class:`SessionStore`.
    :param key:
        The key used to retrieve the instance from the registry. A default
        is used if it is not set.
    :param request:
        A :class:`amanwa32.Request` instance used to retrieve the instance. The
        active request is used if it is not set.
    """
    request = request or amanwa32.get_request()
    request.registry[key] = store


# Don't need to import it. :)
default_config['backends']['securecookie'] = SecureCookieSessionFactory
