"""
Sessions
========

This module contains some helper classes that help one to add session
support to a python WSGI application. For full client-side session
storage see :mod:`secure_cookie.securecookie` which implements a secure,
client-side session storage.


Application Integration
-----------------------

::

    from secure_cookie.sessions import SessionMiddleware
    from secure_cookie.sessions import FilesystemSessionStore
    app = SessionMiddleware(app, FilesystemSessionStore())

The current session will then appear in the WSGI environment as
``werkzeug.session``. However it's recommended to not use the middleware
but the stores directly in the application. However for very simple
scripts a middleware for sessions could be sufficient.

This module does not implement methods or ways to check if a session is
expired. That should be done by a cron job and storage specific. For
example to prune unused filesystem sessions one could check the modified
time of the files. If sessions are stored in the database the ``new()``
method should add an expiration timestamp for the session.

For better flexibility it's recommended to not use the middleware but
the store and session object directly in the application dispatching::

    session_store = FilesystemSessionStore()

    def application(environ, start_response):
        request = Request(environ)
        sid = request.cookies.get('cookie_name')

        if sid is None:
            request.session = session_store.new()
        else:
            request.session = session_store.get(sid)

        response = get_the_response_object(request)

        if request.session.should_save:
            session_store.save(request.session)
            response.set_cookie('cookie_name', request.session.sid)

        return response(environ, start_response)
"""
import os
import re
import tempfile
from hashlib import sha1
from os import path
from pickle import dump
from pickle import HIGHEST_PROTOCOL
from pickle import load
from random import random
from time import time

from werkzeug._compat import PY2
from werkzeug._compat import text_type
from werkzeug.datastructures import CallbackDict
from werkzeug.filesystem import get_filesystem_encoding
from werkzeug.posixemulation import rename
from werkzeug.http import dump_cookie
from werkzeug.http import parse_cookie
from werkzeug.wsgi import ClosingIterator


_sha1_re = re.compile(r"^[a-f0-9]{40}$")


def _urandom():
    if hasattr(os, "urandom"):
        return os.urandom(30)
    return text_type(random()).encode("ascii")


def generate_key(salt=None):
    if salt is None:
        salt = repr(salt).encode("ascii")
    return sha1(b"".join([salt, str(time()).encode("ascii"), _urandom()])).hexdigest()


class ModificationTrackingDict(CallbackDict):
    __slots__ = ("modified",)

    def __init__(self, *args, **kwargs):
        def on_update(self):
            self.modified = True

        self.modified = False
        super(ModificationTrackingDict, self).__init__(on_update=on_update)
        dict.update(self, *args, **kwargs)

    def copy(self):
        """Create a flat copy of the dict."""
        missing = object()
        result = object.__new__(self.__class__)
        for name in self.__slots__:
            val = getattr(self, name, missing)
            if val is not missing:
                setattr(result, name, val)
        return result

    def __copy__(self):
        return self.copy()


class Session(ModificationTrackingDict):
    """Subclass of a dict that keeps track of direct object changes.
    Changes in mutable structures are not tracked, for those you have to
    set ``modified`` to ``True`` by hand.
    """

    __slots__ = ModificationTrackingDict.__slots__ + ("sid", "new")

    def __init__(self, data, sid, new=False):
        super(Session, self).__init__(data)
        self.sid = sid
        self.new = new

    def __repr__(self):
        return "<%s %s%s>" % (
            self.__class__.__name__,
            dict.__repr__(self),
            "*" if self.should_save else "",
        )

    @property
    def should_save(self):
        """True if the session should be saved."""
        return self.modified


class SessionStore(object):
    """Base class for all session stores.

    :param session_class: The session class to use.
    """

    def __init__(self, session_class=Session):
        self.session_class = session_class

    def is_valid_key(self, key):
        """Check if a key has the correct format."""
        return _sha1_re.match(key) is not None

    def generate_key(self, salt=None):
        """Simple function that generates a new session key."""
        return generate_key(salt)

    def new(self):
        """Generate a new session."""
        return self.session_class({}, self.generate_key(), True)

    def save(self, session):
        """Save a session."""

    def save_if_modified(self, session):
        """Save if a session class wants an update."""
        if session.should_save:
            self.save(session)

    def delete(self, session):
        """Delete a session."""

    def get(self, sid):
        """Get a session for this sid or a new session object. This
        method has to check if the session key is valid and create a new
        session if that wasn't the case.
        """
        return self.session_class({}, sid, True)


# Used for temporary files by the filesystem session store.
_fs_transaction_suffix = ".__wz_sess"


class FilesystemSessionStore(SessionStore):
    """Simple example session store that saves sessions on the
    filesystem.

    :param path: The path to the folder used for storing the sessions.
        If not provided the default temporary directory is used.
    :param filename_template: A string template used to give the session
        a filename. ``%s`` is replaced with the session id.
    :param session_class: The session class to use.
    :param renew_missing: Set to ``True`` if you want the store to give
        the user a new sid if the session was not yet saved.
    """

    def __init__(
        self,
        path=None,
        filename_template="werkzeug_%s.sess",
        session_class=Session,
        renew_missing=False,
        mode=0o644,
    ):
        super(FilesystemSessionStore, self).__init__(session_class=session_class)

        if path is None:
            path = tempfile.gettempdir()

        self.path = path

        if isinstance(filename_template, text_type) and PY2:
            filename_template = filename_template.encode(get_filesystem_encoding())

        assert not filename_template.endswith(_fs_transaction_suffix), (
            "filename templates may not end with %s" % _fs_transaction_suffix
        )
        self.filename_template = filename_template
        self.renew_missing = renew_missing
        self.mode = mode

    def get_session_filename(self, sid):
        # Out of the box this should be a strict ASCII subset, but you
        # might reconfigure the session object to have a more arbitrary
        # string.
        if isinstance(sid, text_type) and PY2:
            sid = sid.encode(get_filesystem_encoding())

        return path.join(self.path, self.filename_template % sid)

    def save(self, session):
        fn = self.get_session_filename(session.sid)
        fd, tmp = tempfile.mkstemp(suffix=_fs_transaction_suffix, dir=self.path)
        f = os.fdopen(fd, "wb")

        try:
            dump(dict(session), f, HIGHEST_PROTOCOL)
        finally:
            f.close()

        try:
            rename(tmp, fn)
            os.chmod(fn, self.mode)
        except (IOError, OSError):
            pass

    def delete(self, session):
        fn = self.get_session_filename(session.sid)

        try:
            os.unlink(fn)
        except OSError:
            pass

    def get(self, sid):
        if not self.is_valid_key(sid):
            return self.new()

        try:
            f = open(self.get_session_filename(sid), "rb")
        except IOError:
            if self.renew_missing:
                return self.new()

            data = {}
        else:
            try:
                try:
                    data = load(f)
                except Exception:
                    data = {}
            finally:
                f.close()

        return self.session_class(data, sid, False)

    def list(self):
        """List all sessions in the store."""
        before, after = self.filename_template.split("%s", 1)
        filename_re = re.compile(
            r"%s(.{5,})%s$" % (re.escape(before), re.escape(after))
        )
        result = []
        for filename in os.listdir(self.path):
            #: this is a session that is still being saved.
            if filename.endswith(_fs_transaction_suffix):
                continue
            match = filename_re.match(filename)
            if match is not None:
                result.append(match.group(1))
        return result


class SessionMiddleware(object):
    """A middleware that puts the session object of a store into the
    WSGI environ. It automatically sets cookies and restores sessions.

    However a middleware is not the preferred solution because it won't
    be as fast as sessions managed by the application itself and will
    put a key into the WSGI environment only relevant for the
    application which is against the concept of WSGI.

    The cookie parameters are the same as for the :func:`~dump_cookie`
    function just prefixed with ``cookie_``. Additionally ``max_age`` is
    called ``cookie_age`` and not ``cookie_max_age`` because of
    backwards compatibility.
    """

    def __init__(
        self,
        app,
        store,
        cookie_name="session_id",
        cookie_age=None,
        cookie_expires=None,
        cookie_path="/",
        cookie_domain=None,
        cookie_secure=None,
        cookie_httponly=False,
        cookie_samesite="Lax",
        environ_key="werkzeug.session",
    ):
        self.app = app
        self.store = store
        self.cookie_name = cookie_name
        self.cookie_age = cookie_age
        self.cookie_expires = cookie_expires
        self.cookie_path = cookie_path
        self.cookie_domain = cookie_domain
        self.cookie_secure = cookie_secure
        self.cookie_httponly = cookie_httponly
        self.cookie_samesite = cookie_samesite
        self.environ_key = environ_key

    def __call__(self, environ, start_response):
        cookie = parse_cookie(environ.get("HTTP_COOKIE", ""))
        sid = cookie.get(self.cookie_name, None)

        if sid is None:
            session = self.store.new()
        else:
            session = self.store.get(sid)

        environ[self.environ_key] = session

        def injecting_start_response(status, headers, exc_info=None):
            if session.should_save:
                self.store.save(session)
                headers.append(
                    (
                        "Set-Cookie",
                        dump_cookie(
                            self.cookie_name,
                            session.sid,
                            self.cookie_age,
                            self.cookie_expires,
                            self.cookie_path,
                            self.cookie_domain,
                            self.cookie_secure,
                            self.cookie_httponly,
                            samesite=self.cookie_samesite,
                        ),
                    )
                )

            return start_response(status, headers, exc_info)

        return ClosingIterator(
            self.app(environ, injecting_start_response),
            lambda: self.store.save_if_modified(session),
        )
