# -*- coding: utf-8 -*-
#
# Copyright (C) 2004-2023 Edgewall Software
# Copyright (C) 2004 Daniel Lundin <daniel@edgewall.com>
# Copyright (C) 2004-2006 Christopher Lenz <cmlenz@gmx.de>
# Copyright (C) 2006 Jonas Borgström <jonas@edgewall.com>
# Copyright (C) 2008 Matt Good <matt@matt-good.net>
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at https://trac.edgewall.org/wiki/TracLicense.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
# history and logs, available at https://trac.edgewall.org/log/.
#
# Author: Daniel Lundin <daniel@edgewall.com>
#         Christopher Lenz <cmlenz@gmx.de>

import re

from trac.admin.api import AdminCommandError, IAdminCommandProvider, \
                           console_date_format, get_console_locale
from trac.core import Component, ExtensionPoint, TracError, TracValueError, \
                      implements
from trac.util import as_bool, as_float, as_int, hex_entropy, lazy
from trac.util.datefmt import get_datetime_format_hint, format_date, \
                              parse_date, time_now, to_datetime, to_timestamp
from trac.util.text import print_table
from trac.util.translation import _
from trac.web.api import IRequestHandler, is_valid_default_handler

UPDATE_INTERVAL = 3600 * 24 # Update session last_visit time stamp after 1 day
PURGE_AGE = 3600 * 24 * 90 # Expire cookie after 90 days
COOKIE_KEY = 'trac_session'


# Note: as we often manipulate both the `session` and the
#       `session_attribute` tables, there's a possibility of table
#       deadlocks (#9705). We try to prevent them to happen by always
#       accessing the tables in the same order within the transaction,
#       first `session`, then `session_attribute`.

class SessionDict(dict):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.authenticated = False
        self.last_visit = 0
        self.sid = None

    def __setitem__(self, key, value):
        super().__setitem__(key, str(value))

    def as_bool(self, key, default=None):
        """Return the value as a boolean. Return `default` if
        if an exception is raised while converting the value to a
        boolean.

        :param key: the name of the session attribute
        :keyword default: the value to return if the parameter is not
                          specified or an exception occurs converting
                          the value to a boolean.

        :since: 1.2
        """
        if key not in self:
            return default
        return as_bool(self[key], default)

    def as_int(self, key, default=None, min=None, max=None):
        """Return the value as an integer. Return `default` if
        if an exception is raised while converting the value to an
        integer.

        :param key: the name of the session attribute
        :keyword default: the value to return if the parameter does
                          not exist or an exception occurs converting
                          the value to an integer.
        :keyword min: lower bound to which the value is limited
        :keyword max: upper bound to which the value is limited

        :since: 1.2
        """
        if key not in self:
            return default
        return as_int(self[key], default, min, max)

    def as_float(self, key, default=None, min=None, max=None):
        """Return the value as a float. Return `default` if
        if an exception is raised while converting the value to a
        float.

        :param key: the name of the session attribute
        :keyword default: the value to return if the parameter does
                          not exist or an exception occurs converting
                          the value to a float.
        :keyword min: lower bound to which the value is limited
        :keyword max: upper bound to which the value is limited

        :since: 1.3.6
        """
        if key not in self:
            return default
        return as_float(self[key], default, min, max)

    def set(self, key, value, default=None):
        """Set a variable in the session, or remove it if it's equal to the
        default value.
        """
        value = str(value)
        if default is not None:
            default = str(default)
            if value == default:
                self.pop(key, None)
                return
        super().__setitem__(key, value)


class DetachedSession(SessionDict):

    def __init__(self, env, sid):
        super().__init__()
        self.env = env
        self._new = True
        self._old = {}
        if sid:
            self.get_session(sid, authenticated=True)

    def get_session(self, sid, authenticated=False):
        self.env.log.debug("Retrieving session for ID %r", sid)

        with self.env.db_query as db:
            self.sid = sid
            self.authenticated = authenticated
            self.clear()

            for last_visit, in db("""
                    SELECT last_visit FROM session
                    WHERE sid=%s AND authenticated=%s
                    """, (sid, int(authenticated))):
                self._new = False
                self.last_visit = int(last_visit or 0)
                self.update(db("""
                    SELECT name, value FROM session_attribute
                    WHERE sid=%s and authenticated=%s
                    """, (sid, int(authenticated))))
                self._old = self.copy()
                break
            else:
                self.last_visit = 0
                self._new = True
                self._old = {}

    def save(self):
        authenticated = int(self.authenticated)
        now = int(time_now())
        items = list(self.items())
        if not authenticated and not self._old and not items:
            # The session for anonymous doesn't have associated data,
            # so there's no need to persist it
            return

        # We can't do the session management in one big transaction,
        # as the intertwined changes to both the session and
        # session_attribute tables are prone to deadlocks (#9705).
        # Therefore we first we save the current session, then we
        # eventually purge the tables.

        session_saved = False

        with self.env.db_transaction as db:
            # Try to save the session if it's a new one. A failure to
            # do so is not critical but we nevertheless skip the
            # following steps.

            new = self._new
            if new:
                self.last_visit = now
                self._new = False
                # The session might already exist even if _new is True since
                # it could have been created by a concurrent request (#3563).
                try:
                    db("""INSERT INTO session (sid, last_visit, authenticated)
                          VALUES (%s,%s,%s)
                          """, (self.sid, self.last_visit, authenticated))
                except self.env.db_exc.IntegrityError:
                    self.env.log.warning('Session %s already exists', self.sid)
                    db.rollback()
                    return

            if authenticated and \
                    (new or self._old.get('name') != self.get('name') or
                     self._old.get('email') != self.get('email')):
                self.env.invalidate_known_users_cache()

            # Remove former values for session_attribute and save the
            # new ones. The last concurrent request to do so "wins".

            if self._old != self:
                if not items and not authenticated:
                    # No need to keep around empty unauthenticated sessions
                    db("DELETE FROM session WHERE sid=%s AND authenticated=0",
                       (self.sid,))
                db("""DELETE FROM session_attribute
                      WHERE sid=%s AND authenticated=%s
                      """, (self.sid, authenticated))
                self._old = dict(self.items())
                # The session variables might already have been updated by a
                # concurrent request.
                try:
                    db.executemany("""
                        INSERT INTO session_attribute
                          (sid,authenticated,name,value)
                        VALUES (%s,%s,%s,%s)
                        """, [(self.sid, authenticated, k, v)
                              for k, v in items])
                except self.env.db_exc.IntegrityError:
                    self.env.log.warning('Attributes for session %s already '
                                         'updated', self.sid)
                    db.rollback()
                    return
                session_saved = True

        # Purge expired sessions. We do this only when the session was
        # changed as to minimize the purging.

        if session_saved and now - self.last_visit > UPDATE_INTERVAL:
            self.last_visit = now
            lifetime = self.env.anonymous_session_lifetime
            mintime = now - lifetime * 86400 if lifetime > 0 else None

            with self.env.db_transaction as db:
                # Update the session last visit time if it is over an
                # hour old, so that session doesn't get purged
                self.env.log.info("Refreshing session %s", self.sid)
                db("""UPDATE session SET last_visit=%s
                      WHERE sid=%s AND authenticated=%s
                      """, (self.last_visit, self.sid, authenticated))
                if mintime:
                    self.env.log.debug('Purging old, expired, sessions.')
                    db("""DELETE FROM session_attribute
                          WHERE authenticated=0 AND sid IN (
                              SELECT sid FROM session
                              WHERE authenticated=0 AND last_visit < %s
                          )
                          """, (mintime,))

            # Avoid holding locks on lot of rows on both session_attribute
            # and session tables
            if mintime:
                with self.env.db_transaction as db:
                    db("""
                        DELETE FROM session
                        WHERE authenticated=0 AND last_visit < %s
                        """, (mintime,))


class Session(DetachedSession):
    """Basic session handling and per-session storage."""

    def __init__(self, env, req):
        super().__init__(env, None)
        self.req = req
        if not req.is_authenticated:
            if COOKIE_KEY not in req.incookie:
                self.sid = hex_entropy(24)
                self.bake_cookie()
            else:
                sid = req.incookie[COOKIE_KEY].value
                self.get_session(sid)
        else:
            if COOKIE_KEY in req.incookie:
                sid = req.incookie[COOKIE_KEY].value
                self.promote_session(sid)
            self.get_session(req.authname, authenticated=True)

    def bake_cookie(self, expires=PURGE_AGE):
        assert self.sid, 'Session ID not set'
        self.req.outcookie[COOKIE_KEY] = self.sid
        self.req.outcookie[COOKIE_KEY]['path'] = self.req.base_path or '/'
        self.req.outcookie[COOKIE_KEY]['expires'] = expires
        if self.env.secure_cookies:
            self.req.outcookie[COOKIE_KEY]['secure'] = True
        self.req.outcookie[COOKIE_KEY]['httponly'] = True

    _valid_sid_re = re.compile(r'[_A-Za-z0-9]+\Z')

    def get_session(self, sid, authenticated=False):
        refresh_cookie = False

        if not authenticated and not self._valid_sid_re.match(sid):
            raise TracValueError(_("Session ID must be alphanumeric."))
        if self.sid and sid != self.sid:
            refresh_cookie = True

        super().get_session(sid, authenticated)
        if self.last_visit and time_now() - self.last_visit > UPDATE_INTERVAL:
            refresh_cookie = True

        # Refresh the session cookie if this is the first visit after a day
        if not authenticated and refresh_cookie:
            self.bake_cookie()

    def change_sid(self, new_sid):
        assert not self.req.is_authenticated, \
               'Cannot change ID of authenticated session'
        assert new_sid, 'Session ID cannot be empty'
        if new_sid == self.sid:
            return
        if not self._valid_sid_re.match(new_sid):
            raise TracValueError(_("Session ID must be alphanumeric."),
                                 _("Error renaming session"))
        with self.env.db_transaction as db:
            if db("SELECT sid FROM session WHERE sid=%s", (new_sid,)):
                raise TracError(_("Session '%(id)s' already exists. "
                                  "Please choose a different session ID.",
                                  id=new_sid),
                                _("Error renaming session"))
            self.env.log.debug("Changing session ID %s to %s", self.sid,
                               new_sid)
            db("UPDATE session SET sid=%s WHERE sid=%s AND authenticated=0",
               (new_sid, self.sid))
            db("""UPDATE session_attribute SET sid=%s
                  WHERE sid=%s and authenticated=0
                  """, (new_sid, self.sid))
        self.sid = new_sid
        self.bake_cookie()

    def promote_session(self, sid):
        """Promotes an anonymous session to an authenticated session, if there
        is no preexisting session data for that user name.
        """
        assert self.req.is_authenticated, \
               "Cannot promote session of anonymous user"

        with self.env.db_transaction as db:
            authenticated_flags = [authenticated for authenticated, in db(
                "SELECT authenticated FROM session WHERE sid=%s OR sid=%s",
                (sid, self.req.authname))]

            if len(authenticated_flags) == 2:
                # There's already an authenticated session for the user,
                # we simply delete the anonymous session
                db("DELETE FROM session WHERE sid=%s AND authenticated=0",
                   (sid,))
                db("""DELETE FROM session_attribute
                      WHERE sid=%s AND authenticated=0
                      """, (sid,))
            elif len(authenticated_flags) == 1:
                if not authenticated_flags[0]:
                    # Update the anonymous session records so the session ID
                    # becomes the user name, and set the authenticated flag.
                    self.env.log.debug("Promoting anonymous session %s to "
                                       "authenticated session for user %s",
                                       sid, self.req.authname)
                    db("""UPDATE session SET sid=%s, authenticated=1
                          WHERE sid=%s AND authenticated=0
                          """, (self.req.authname, sid))
                    db("""UPDATE session_attribute SET sid=%s, authenticated=1
                          WHERE sid=%s
                          """, (self.req.authname, sid))
            else:
                # We didn't have an anonymous session for this sid. The
                # authenticated session might have been inserted between the
                # SELECT above and here, so we catch the error.
                try:
                    db("""INSERT INTO session (sid, last_visit, authenticated)
                          VALUES (%s, %s, 1)
                          """, (self.req.authname, int(time_now())))
                except self.env.db_exc.IntegrityError:
                    self.env.log.warning('Authenticated session for %s '
                                         'already exists', self.req.authname)
                    db.rollback()
        self._new = False

        self.sid = sid
        self.bake_cookie(0)  # expire the cookie


class SessionAdmin(Component):
    """trac-admin command provider for session management"""

    implements(IAdminCommandProvider)

    request_handlers = ExtensionPoint(IRequestHandler)

    def get_admin_commands(self):
        hints = {
            'datetime': get_datetime_format_hint(get_console_locale(self.env)),
            'iso8601': get_datetime_format_hint('iso8601'),
        }
        yield ('session list', '[sid[:0|1]] [...]',
               """List the name and email for the given sids

               Specifying the sid 'anonymous' lists all unauthenticated
               sessions, and 'authenticated' all authenticated sessions.
               '*' lists all sessions, and is the default if no sids are
               given.

               An sid suffix ':0' operates on an unauthenticated session with
               the given sid, and a suffix ':1' on an authenticated session
               (the default).""",
               self._complete_list, self._do_list)

        yield ('session add', '<sid[:0|1]> [name] [email]',
               """Create a session for the given sid

               Populates the name and email attributes for the given session.
               Adding a suffix ':0' to the sid makes the session
               unauthenticated, and a suffix ':1' makes it authenticated (the
               default if no suffix is specified).""",
               None, self._do_add)

        yield ('session set', '<name|email|default_handler> '
                              '<sid[:0|1]> <value>',
               """Set the name or email attribute of the given sid

               An sid suffix ':0' operates on an unauthenticated session with
               the given sid, and a suffix ':1' on an authenticated session
               (the default).""",
               self._complete_set, self._do_set)

        yield ('session delete', '<sid[:0|1]> [...]',
               """Delete the session of the specified sid

               An sid suffix ':0' operates on an unauthenticated session with
               the given sid, and a suffix ':1' on an authenticated session
               (the default). Specifying the sid 'anonymous' will delete all
               anonymous sessions.""",
               self._complete_delete, self._do_delete)

        yield ('session purge', '<age>',
               """Purge anonymous sessions older than given age or date

               Age may be specified as a relative time like "90 days ago", or
               as a date in the "%(datetime)s" or "%(iso8601)s" (ISO 8601)
               format.""" % hints,
               None, self._do_purge)

    @lazy
    def _valid_default_handlers(self):
        return sorted(handler.__class__.__name__
                      for handler in self.request_handlers
                      if is_valid_default_handler(handler))

    def _split_sid(self, sid):
        if sid.endswith(':0'):
            return sid[:-2], 0
        elif sid.endswith(':1'):
            return sid[:-2], 1
        else:
            return sid, 1

    def _get_sids(self):
        rows = self.env.db_query("SELECT sid, authenticated FROM session")
        return ['%s:%d' % (sid, auth) for sid, auth in rows]

    def _get_list(self, sids):
        all_anon = 'anonymous' in sids or '*' in sids
        all_auth = 'authenticated' in sids or '*' in sids
        sids = {self._split_sid(sid)
                for sid in sids
                if sid not in ('anonymous', 'authenticated', '*')}
        # Use sort() instead of "ORDER BY s.sid, s.authenticated" in order to
        # avoid filesort caused by indexing only a prefix of column values on
        # MySQL.
        rows = self.env.db_query("""
            SELECT s.sid, s.authenticated, s.last_visit, n.value, e.value,
                   h.value
            FROM session AS s
              LEFT JOIN session_attribute AS n
                ON (n.sid=s.sid AND n.authenticated=s.authenticated
                    AND n.name='name')
              LEFT JOIN session_attribute AS e
                ON (e.sid=s.sid AND e.authenticated=s.authenticated
                    AND e.name='email')
              LEFT JOIN session_attribute AS h
                ON (h.sid=s.sid AND h.authenticated=s.authenticated
                    AND h.name='default_handler')
            """)
        rows.sort(key=lambda u: (u[0], u[1]))
        for sid, authenticated, last_visit, name, email, handler in rows:
            if all_anon and not authenticated or all_auth and authenticated \
                    or (sid, authenticated) in sids:
                yield (sid, authenticated,
                       format_date(to_datetime(last_visit),
                                   console_date_format),
                       name, email, handler)

    def _complete_list(self, args):
        all_sids = self._get_sids() + ['*', 'anonymous', 'authenticated']
        return set(all_sids) - set(args)

    def _complete_set(self, args):
        if len(args) == 1:
            return ['name', 'email']
        elif len(args) == 2:
            return self._get_sids()

    def _complete_delete(self, args):
        all_sids = self._get_sids() + ['anonymous']
        return set(all_sids) - set(args)

    def _do_list(self, *sids):
        if not sids:
            sids = ['*']
        headers = (_("SID"), _("Auth"), _("Last Visit"), _("Name"),
                   _("Email"), _("Default Handler"))
        print_table(self._get_list(sids), headers)

    def _do_add(self, sid, name=None, email=None):
        sid, authenticated = self._split_sid(sid)
        with self.env.db_transaction as db:
            try:
                db("INSERT INTO session VALUES (%s, %s, %s)",
                   (sid, authenticated, int(time_now())))
            except Exception:
                raise AdminCommandError(_("Session '%(sid)s' already exists",
                                          sid=sid))
            if name:
                db("INSERT INTO session_attribute VALUES (%s,%s,'name',%s)",
                    (sid, authenticated, name))
            if email:
                db("INSERT INTO session_attribute VALUES (%s,%s,'email',%s)",
                    (sid, authenticated, email))
        self.env.invalidate_known_users_cache()

    def _do_set(self, attr, sid, val):
        if attr not in ('name', 'email', 'default_handler'):
            raise AdminCommandError(_("Invalid attribute '%(attr)s'",
                                      attr=attr))
        if attr == 'default_handler':
            if val and val not in self._valid_default_handlers:
                raise AdminCommandError(_("Invalid default_handler '%(val)s'",
                                          val=val))
        sid, authenticated = self._split_sid(sid)
        with self.env.db_transaction as db:
            if not db("""SELECT sid FROM session
                         WHERE sid=%s AND authenticated=%s""",
                         (sid, authenticated)):
                raise AdminCommandError(_("Session '%(sid)s' not found",
                                          sid=sid))
            db("""
                DELETE FROM session_attribute
                WHERE sid=%s AND authenticated=%s AND name=%s
                """, (sid, authenticated, attr))
            if val:
                db("INSERT INTO session_attribute VALUES (%s, %s, %s, %s)",
                   (sid, authenticated, attr, val))
        self.env.invalidate_known_users_cache()

    def _do_delete(self, *sids):
        with self.env.db_transaction as db:
            for sid in sids:
                sid, authenticated = self._split_sid(sid)
                if sid == 'anonymous':
                    db("DELETE FROM session WHERE authenticated=0")
                    db("DELETE FROM session_attribute WHERE authenticated=0")
                else:
                    db("""
                        DELETE FROM session
                        WHERE sid=%s AND authenticated=%s
                        """, (sid, authenticated))
                    db("""
                        DELETE FROM session_attribute
                        WHERE sid=%s AND authenticated=%s
                        """, (sid, authenticated))
        self.env.invalidate_known_users_cache()

    def _do_purge(self, age):
        when = parse_date(age, hint='datetime',
                          locale=get_console_locale(self.env))
        with self.env.db_transaction as db:
            ts = to_timestamp(when)
            db("""
                DELETE FROM session
                WHERE authenticated=0 AND last_visit<%s
                """, (ts,))
            db("""
                DELETE FROM session_attribute
                WHERE authenticated=0
                      AND NOT EXISTS (SELECT * FROM session AS s
                                      WHERE s.sid=session_attribute.sid
                                      AND s.authenticated=0)
                """)


def get_session_attribute(env, sid, authenticated, name, default=None):
    for row in env.db_query("""
            SELECT value FROM session_attribute
            WHERE sid=%s AND authenticated=%s AND name=%s
            """, (sid, 1 if authenticated else 0, name)):
        return row[0]
    else:
        return default
